Open5

趣味のコンポーネント生活 2023-10

hirospherehirosphere

9月号からの続きです。

https://zenn.dev/hirosphere/scraps/8e9647e5f7ecd3

「コンポーネントライブラリ」には何が必要?

シングルページアプリケーションをより簡潔に作るために必要なのは、

  • エレメント構造の簡潔な表現
  • 反応性! 「モデルからビューへのリアクティビティー」
    • 値 : Value
    • 存在 : Existence
    • その構造 ( 順序・リスト・ツリー ) : Order | Structure
  • ルーティング:URLパス → 内容表示

などでしょうか。

とりあえず、エレメントの作成と「値」オブジェクトが、自作ライブラリ「Meh:メェ」として用意できたので、「HTMLコンポーネント」のサンプルをつくってみました。

観測点リストの位置情報で点描日本地図

防災科学研究所地震観測点CSV に含まれる位置情報で点アイテムをプロットして出来た、地図状の選択リストコンポーネントです。

このような、1747行のCSVデータから、

export const sitepub = 

`AIC001,尾西,BISAI,35.2974,136.7505,5,NA,愛知県,AICHIKEN,K-NET11A,,
AIC002,小牧,KOMAKI,35.2979,136.9154,21,NA,愛知県,AICHIKEN,K-NET11A,,
AIC003,津島,TSUSHIMA,35.1734,136.7402,-1,NA,愛知県,AICHIKEN,K-NET11A,,
AIC004,名古屋,NAGOYA,35.0631,136.9738,30,NA,愛知県,AICHIKEN,K-NET11A,,
AIC005,藤岡,FUJIOKA,35.1983,137.2061,134,NA,愛知県,AICHIKEN,K-NET11A,,
AIC006,稲武,INABU,35.2158,137.5089,505,NA,愛知県,AICHIKEN,K-NET11A,,
AIC007,足助,ASUKE,35.1394,137.3353,158,NA,愛知県,AICHIKEN,---,休止,suspension
AIC008,設楽,SHITARA,35.1006,137.5757,479,NA,愛知県,AICHIKEN,K-NET11A,,
AIC009,豊田,TOYOTA,35.0813,137.1466,55,NA,愛知県,AICHIKEN,K-NET11A,,
AIC010,作手,TSUKUDE,34.9803,137.4297,539,NA,愛知県,AICHIKEN,K-NET11A,,
AIC011,知多,CHITA,34.9966,136.8641,5,NA,愛知県,AICHIKEN,K-NET11A,,
AIC012,安城,ANJO,34.9119,137.0446,4,NA,愛知県,AICHIKEN,K-NET11A,,
AIC013,長篠,NAGASHINO,34.9335,137.5749,80,NA,愛知県,AICHIKEN,K-NET11A,,
...

こんな感じのコンポーネントが得られました。

hirospherehirosphere

サンプルアプリのディレクトリ構成

  • 101-EQ-Map
    • index.html
    • js : ts-src のアウトプット
    • ts-src : TypeScript ソース
      • ts-config.json
      • app.ts
      • eq : 地震アプレット
        • map.ts : 点描マップコンポーネント
      • meh : メェ・自作コンポーネント/データライブラリ
        • dom : HTMLエレメント生成
          • ef.ts : エレメントファクトリー
          • compo.ts : 「エレメント」定義と生成
        • model
          • leaf.ts :
            string, number, boolean その他をリアクティブ化する「葉っぱ」データオブジェクト。

のようになっています。

GitHubリポジトリ はこちら、GH-Pages 動作サンプル はこちらです。

hirospherehirosphere

JSXのように一段インデントを実現!

リアクティブDOMライブラリ VanJS のように「関数とその引数により、エレメントツリーの構成をJSXのようにインデント一段で表現」を真似てみた今回の自作ライブラリ。

下記の応用例 map.ts 中の3種のUIコンポーネント UI { Site, MapFrame, Map } もいい感じに簡潔に表現できたと思います。

UIコンポーネントの「ステート」部分は「Model { Site, Map}」として分離しています。

map.tsのUI部分
import { Leaf, leaf, ef } from "../meh/index.js";
import { sitepub } from "./sitepub_all_utf8.js";
import { Range } from "../range.js";
const log = console.log;

const clip = ( value : number, min : number, max : number ) => Math.max( min, Math.min( max, value ) );

type LatLong = { lat : number, long : number };
const pxRatio = 100;

// Model //

namespace Model
{
	..... 省略
}


// UI //

namespace UI
{
	const { div, h2, h3, } = ef;

	const Site = ( site : Model.Site, map : Model.Map ) =>
	{
		const { left, top } = map.cssPos( site );

		const props =
		{
			class: [ "map-site", { selected: site.selected } ],
			styles: { left, top },
			onmouseover( ev : MouseEvent )
			{
				map.hover.value = site;
			},
			onclick()
			{
				map.current.value = site;
			}
		};

		return div( props );
	};

	const MapFrame = ( model : Model.Map ) =>
	{
		const onwheel = ( ev : WheelEvent ) => model.putWheelEvent( ev );
	
		const content = div
		(
			{ class: "map-content", styles: { transform: model.scrollCSS } },
			... Model.Site.list.map( siteInfo => Site( siteInfo, model ) )
		);
	
		const zoomFrame = div( { class: "map-zoom", styles: { transform: model.zoomCSS } }, content );
	
		return div( { class: "map-frame", onwheel }, div(), div(), div(), zoomFrame );
	};
	
	export const Map = () =>
	{
		const model = new Model.Map;
	
		return div( { class: "Map" },
			h2( "EQ Site Map" ),
			div( { class: "map-cur-site" }, model.current.str( { toref: site => site && `${ site.code } ${ site.name } ${ site.nameR }` || "" } ) ),
			MapFrame( model ),
			div( { class: "hover-info" }, model.hoverInfo ),
			div( Range.UI( { title: "拡大", value: model.zoom, max: 10 } ) ),
			div( model.wheelMon ),
		);
	}
}

export const Map = UI.Map;

「インデント1段エレメント構成法」が得られても、ウェブでの表示は tab = spc * 8 で何だか。...

hirospherehirosphere

「属性」「アクション」「なま属性」やはり分けて。

いままで作って使ったDOMコンポーネントライブラリのJSON式エレメント記述では、

const { value, min, max } = model;

const input =
{
  type: "input",
  class: "range-input",
  attrs: { type: "range" },
  actions: { input( ev ) { value.set( Number( ev.target.value ) ); } },
  props: { value, min, max }
};

のように、クラス名, DOM属性, イベントハンドラ, エレメントプロパティーを分けて記述し、setAttributeやaddEventListenerなどで実エレメントに与えていました。

今回、VanJSをとりあえず真似て、

const { value, min, max } = model;
const { input } = ef;

const inputdef = input
(
  {
    className: "range-input",
    type: "range",
    oninput( ev : InputEvent ) { value.set( ev.target.value ); },
    value, nim, max
  }
);

のようにDOMのエレメントプロパティーの定義どおりに「平べったく」与え、エレメント作成時に e[ name ] = value; のように直接代入する仕様にしてみましたが、しかし実行時に、

のよう警告が出て、ホイールイベントやタッチイベントで preventDefault() する「active 能動的」ハンドラではユーザーの使用感に悪影響があるので、「passive 受動的であるかどうか」を併せて登録するべきということで、インデントの層は増えてしまいますが「立体的」記述にもどし、addEventListenerで登録する方式に変えることにします。

また、CSSセレクタで[属性]を利用するときも、エレメントプロパティーへの代入ではなく「setAttribute」で設定した値でないとたしか反映されなかったので、「立体式」で分けるのが好都合です。

それぞれのフィールドの命名は..

簡潔にするため attributes は attrs、properties は props で良いとして「eventHandlers」は今まで actions を縮めて acts などと表現していましたが、

今回、それを addEventListener のオプションの passive: true | false に分けないといけないので、2種類のフィールドを用意するか、ひとつで act | [ act, option ] のように切り分けるか .. 両方作って使い勝手を試してみましょう。..

hirospherehirosphere

「立体化」した方がすっきり。

エレメント属性定義 ( CSSクラス名・プロパティー・アトリビュート・アクション ) のネスト・インデントは深くなってしまいましたが、やはりこの方が異種のものが混じらずにスッキリします。

{ passive: true } なイベントアクションは acts: {}, オプションオブジェクトを一緒に渡せる方は optActs: {} と名付けてみました。

下記の例では optActs は、MapFrame コンポーネントの wheel と touchmove に使い、「passiveなのにpreventDefault() するなよ!」の警告も解消されました。

EQ-Map - GH-Pages 動作サンプル
map.ts - GitHub コード

namespace UI
{
    const { div, h2, h3, textarea, } = ef;

    const Site = ( site : Model.Site, map : Model.Map ) =>
    {
        const { left, top } = map.cssPos( site );

        return div
        (
            {
                class: [ "map-site", { selected: site.selected } ],
                style: { left, top },
                attrs: { selected: site.selected },
                acts:
                {
                    mouseover()
                    {
                        map.hover.value = site;
                    },
                    click()
                    {
                        map.current.value = site;
                    },	
                }	
            }
        );
    };

    //

    class ZoomWork
    {
        wheelMon = new Leaf.String( "" );
        wheelZoom : number;

        touchMon = new Leaf.String( "" );

        constructor( protected map : Model.Map )
        {
            this.wheelZoom = map.zoom.value;
        }

        putWheelEvent( ev : WheelEvent )
        {
            if( ! ev.cancelable ) return;

            const mode : "scroll" | "zoom" = ( ev.deltaY % 1  ? "zoom" : "scroll" )
            this.wheelMon.value = `${ mode } ${ ev.deltaX } ${ ev.deltaY }`;
    
            if( mode == "zoom" )
            {
                const zoom = this.wheelZoom + ev.deltaY * -0.1;
                this.wheelZoom = Math.min( 10, Math.max( 0, zoom ) );
                this.map.zoom.value = Math.round( this.wheelZoom );
            }
    
            else // mode == "scroll"
                this.scroll( ev.deltaX, ev.deltaY );
            
            ev.preventDefault();
        }
    
        putTouchEvent( ev : TouchEvent )
        {
            const t0 = ev.touches[ 0 ];

            this.touchMon.value = `${ ev.touches.length } ${ t0.clientX } ${ t0.clientY }, ${ ev.target?.constructor.name } }`;

            if( ev.cancelable ) ev.preventDefault();
        }

        scroll( deltaX : number, deltaY : number )
        {
            const center = this.map.center.value;
            const scale = 0.01 / this.map.zoomScale;

            const long = clip
            (
                center.long + deltaX * scale,
                this.map.long.min,
                this.map.long.max
            );
            
            const lat = clip
            (
                center.lat - deltaY * scale,
                this.map.lat.min,
                this.map.lat.max
            );

            this.map.center.value = { lat, long };
        }
    }

    const MapFrame = ( model : Model.Map, zoom_wk : ZoomWork ) =>
    {
        const content = div
        (
            {
                class: "map-content",
                style: { transform: model.scrollCSS }
            },

            ... Model.Site.list.map( siteInfo => Site( siteInfo, model ) )
        );
    
        const zoomFrame = div
        (
            {
                class: "map-zoom",
                style: { transform: model.zoomCSS }
            },
            
            content
        );
    

        const wheel = ( ev : WheelEvent ) => zoom_wk.putWheelEvent( ev );
        const touchmove = ( ev : TouchEvent ) => zoom_wk.putTouchEvent( ev );
        const opt = { passive: false };
    
        return div
        (
            {
                class: "map-frame",
                optActs: { wheel: [ wheel, opt ], touchmove: [ touchmove, opt ] }
            },

            div(),
            div(),
            div(),
            zoomFrame
        );
    };
    
    export const Map = () =>
    {
        const model = new Model.Map;
        const lianMon = new Leaf.String( "" );
        const zoom_wk = new ZoomWork( model );

        model.hoverList.ref
        (
            () =>
            {
                const lian = model.hoverList;
                const list = lian.slice( -130 ).map( site => `${ site.name }` );
                lianMon.value = "" + lian.length + " " + list.join( ", ")
            }
        );

        return div( { class: "map applet" },
            
            h2( "EQ Site Map" ),

            div( { class: "map-cur-site" }, model.currentInfo ),
            MapFrame( model, zoom_wk ),
            div( { class: "hover-info" }, model.hoverInfo ),
            div( Range.UI( { title: "拡大", value: model.zoom, max: 10 } ) ),
            
            div( "Wheel", " ", zoom_wk.wheelMon ),
            div( "Touch", " ", zoom_wk.touchMon ),
        
            h3( "ホバー履歴" ),
            div( defs.lp( model.hoverList, item => div( item.code ) ) ),
            textarea( { props: { value: lianMon }, style: { width: "100%", height: "20em" } } ),
        );
    }
}

export const Map = UI.Map;

さて、「構造のリアクティビティー」を。

「プリミティブ値のリアクティビティー」は meh.model.Leaf オブジェクトとして得られましたが、リアクティブな Array を考えてみないとです。

Array を add / remove すると、DOMエレメントも勝手に追従してくれるような仕組みを。 ..