趣味のコンポーネント生活 2023-10
9月号からの続きです。
「コンポーネントライブラリ」には何が必要?
シングルページアプリケーションをより簡潔に作るために必要なのは、
- エレメント構造の簡潔な表現
- 反応性! 「モデルからビューへのリアクティビティー」
- 値 : 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,,
...
こんな感じのコンポーネントが得られました。
サンプルアプリのディレクトリ構成
- 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 その他をリアクティブ化する「葉っぱ」データオブジェクト。
- leaf.ts :
- dom : HTMLエレメント生成
のようになっています。
GitHubリポジトリ はこちら、GH-Pages 動作サンプル はこちらです。
JSXのように一段インデントを実現!
リアクティブDOMライブラリ VanJS のように「関数とその引数により、エレメントツリーの構成をJSXのようにインデント一段で表現」を真似てみた今回の自作ライブラリ。
下記の応用例 map.ts 中の3種のUIコンポーネント UI { Site, MapFrame, Map } もいい感じに簡潔に表現できたと思います。
UIコンポーネントの「ステート」部分は「Model { Site, Map}」として分離しています。
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 で何だか。...
「属性」「アクション」「なま属性」やはり分けて。
いままで作って使った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 ] のように切り分けるか .. 両方作って使い勝手を試してみましょう。..
「立体化」した方がすっきり。
エレメント属性定義 ( 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エレメントも勝手に追従してくれるような仕組みを。 ..