From 2a52e0546e362a6c7d9b2f360d956d13e3bf245e Mon Sep 17 00:00:00 2001 From: Aaron Axvig Date: Fri, 26 Jun 2026 11:23:09 -0500 Subject: [PATCH 01/23] Prototype --- .vscode/settings.json | 8 ++ AGENTS.md | 97 ++++++++++++++ README.md | 109 +++++++++++++++ buildings.gpkg | Bin 0 -> 217088 bytes index.html | 50 +++++++ package.json | 5 + src/data/anchors.js | 7 + src/data/boundaries.js | 19 +++ src/data/buildings.js | 42 ++++++ src/data/irrigation.js | 8 ++ src/data/pavement.js | 51 +++++++ src/data/plants.js | 106 +++++++++++++++ src/data/settings.js | 1 + src/data/yard.js | 20 +++ src/lib/feature-types.js | 125 ++++++++++++++++++ src/lib/geometry.js | 204 ++++++++++++++++++++++++++++ src/lib/yard-features.js | 279 +++++++++++++++++++++++++++++++++++++++ src/main.js | 139 +++++++++++++++++++ styles.css | 136 +++++++++++++++++++ 19 files changed, 1406 insertions(+) create mode 100644 .vscode/settings.json create mode 100644 AGENTS.md create mode 100644 README.md create mode 100644 buildings.gpkg create mode 100644 index.html create mode 100644 package.json create mode 100644 src/data/anchors.js create mode 100644 src/data/boundaries.js create mode 100644 src/data/buildings.js create mode 100644 src/data/irrigation.js create mode 100644 src/data/pavement.js create mode 100644 src/data/plants.js create mode 100644 src/data/settings.js create mode 100644 src/data/yard.js create mode 100644 src/lib/feature-types.js create mode 100644 src/lib/geometry.js create mode 100644 src/lib/yard-features.js create mode 100644 src/main.js create mode 100644 styles.css diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..9c25661 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,8 @@ +{ + "chat.tools.terminal.autoApprove": { + "/^python3 -m http\\.server 4173$/": { + "approve": true, + "matchCommandLine": true + } + } +} \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..047b4ed --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,97 @@ +# Yard Map Agent Notes + +This file is a fast handoff for future coding agents working in this repo. + +## What this project is + +Static client-side yard mapping app. + +- Rendering: Leaflet. +- Basemap: public OSM raster tiles. +- Yard data: authored in JavaScript modules, converted to GeoJSON in the browser. +- Runtime: plain ES modules, no bundler. + +## Current architecture + +Core files: + +- `src/lib/geometry.js`: absolute points, local offsets, feet conversion, per-anchor rotation support. +- `src/lib/feature-types.js`: feature-kind style defaults. +- `src/lib/yard-features.js`: typed feature classes, stable ids, groups, parent-child relationships, GeoJSON export. +- `src/data/*.js`: yard content split by domain. +- `src/main.js`: Leaflet map, legend, popup rendering. + +Top-level exported data shape from `src/data/yard.js`: + +- `type: "FeatureCollection"` +- `features: [...]` +- `groups: [...]` + +Each feature currently exports: + +- `properties.id` +- `properties.kind` +- `properties.label` +- `properties.name` +- `properties.parentId` +- `properties.groupIds` +- `properties.details` +- `properties.style` + +## Important decisions already made + +1. Data is JS-authored on purpose. + This allows mixing exact imported geometry with expressions like `offset(anchor, east(ft(10)))`. + +2. Styling belongs to feature types, not individual features by default. + If you need a new semantic type, prefer adding a new typed feature or feature-type definition rather than hand-styling scattered instances. + +3. Local rotation is per anchor. + `offset(...)` reads `assumedNorthClockwiseDegrees` from the anchor point used as the origin. Do not reintroduce a global lot rotation unless there is a strong reason. + +4. Grouping and parent-child metadata are already part of the data model. + Future visibility controls should consume `groupIds` and `parentId` rather than inventing a second relationship system. + +5. The GPKG was only a seed source. + The app does not depend on GIS tooling at runtime. + +## Known data caveat + +`buildings.gpkg` has a CRS issue in `lot_boundary`. + +- The usable lot polygon was `fid = 2`. +- It had to be treated as `EPSG:26914` during extraction. +- Do not trust that layer's metadata blindly if you re-import from the GPKG. + +## What is already modeled + +- Porch and deck are children of the main house and also part of the `House Parts` group. +- Medora junipers are part of the `Juniper Trees` group. +- A test tree exists that is offset from the top-left lot corner using per-anchor rotation. + +## Likely next steps + +1. Add show/hide controls driven by `yardGeoJSON.groups` and possibly `parentId`. +2. Decide whether toggles should be by feature kind, by group, by parent object, or a combination. +3. If closer basemap zoom fidelity matters, migrate from raster OSM tiles to a vector tile or self-hosted basemap. + +## Useful validation commands + +Validate data export: + +```bash +node -e "import('./src/data/yard.js').then(({ yardGeoJSON }) => console.log(yardGeoJSON.features.length))" +``` + +Run local server: + +```bash +python3 -m http.server 4173 +``` + +## Editing guidance + +- Preserve the typed-feature pattern. +- Prefer adding semantic types or groups over ad hoc properties when possible. +- Keep authored geometry readable; avoid generated noise unless the user explicitly wants bulk-import output. +- If adding new anchors, attach rotation metadata to the anchor itself. diff --git a/README.md b/README.md new file mode 100644 index 0000000..5a78759 --- /dev/null +++ b/README.md @@ -0,0 +1,109 @@ +# Yard Map + +Static yard mapping site built with Leaflet and plain browser modules. + +## Status + +The project currently has: + +- A static Leaflet map with OSM raster tiles. +- Client-side JavaScript-authored yard geometry converted to GeoJSON at load time. +- Typed feature classes with centralized styling. +- Per-anchor local coordinate rotation for offset-based authoring. +- Feature relationship metadata (`id`, `parentId`, `groupIds`) and a top-level group catalog for future visibility controls. +- Example yard data mostly copied from `buildings.gpkg` and then preserved as JS modules. + +The next likely product direction is map-layer visibility controls driven by the existing feature groups and parent-child relationships. + +## Authoring model + +Geometry stays in JavaScript, then gets converted to GeoJSON client-side at load time. + +- Use absolute coordinates with `point(lat, lon)`. +- Use absolute GeoJSON-style coordinates with `lngLat(lon, lat)` or `.trace([[lon, lat], ...])`. +- Use local offsets with `offset(origin)` inside `.add(...)`, for example `.add(offset(lotCorner), south(ft(15)), east(ft(10)))`. +- Build typed features with classes like `new House()`, `new Garage()`, `new Tree()`, and `new Sprinkler()` so styles live in one place. +- Add relationships with `.childOf(...)` and `.inGroup(...)` so future UI can show or hide related features together. + +The example yard is now split by concern: + +- `src/data/boundaries.js` +- `src/data/buildings.js` +- `src/data/pavement.js` +- `src/data/plants.js` +- `src/data/irrigation.js` + +The typed feature registry lives in `src/lib/feature-types.js`, feature classes live in `src/lib/yard-features.js`, and shared coordinate helpers live in `src/lib/geometry.js`. + +Local `north`, `south`, `east`, and `west` offsets are rotated per anchor. In this example, `topLeftLotCorner` in `src/data/anchors.js` carries `assumedNorthClockwiseDegrees` from `src/data/settings.js`. A negative value means that anchor's local north is counterclockwise from true north, so local east drifts a bit toward true north. + +Most example geometry was copied from `buildings.gpkg` and preserved as JS-authored coordinates. Tree records also carry custom attributes like canopy diameter, height, and planted date for popup rendering. + +Feature relationships are exported with each GeoJSON feature as stable `id`, `parentId`, and `groupIds` properties, and the collection also exposes a top-level `groups` array. The current sample marks the porch and deck as part of the house and groups the Medora junipers under `Juniper Trees`. + +## Project structure + +- `src/lib/geometry.js`: low-level coordinate and offset helpers. +- `src/lib/feature-types.js`: centralized default styling by feature kind. +- `src/lib/yard-features.js`: typed feature classes, feature/group metadata, and GeoJSON export. +- `src/data/settings.js`: local authoring settings such as lot rotation. +- `src/data/anchors.js`: named anchor points for offset-based authoring. +- `src/data/boundaries.js`: lot boundary and fence features. +- `src/data/buildings.js`: house, garage, porch, deck, and building-related groups. +- `src/data/pavement.js`: driveway and walkway geometry. +- `src/data/plants.js`: flower beds, trees, and tree groups. +- `src/data/irrigation.js`: sprinkler features. +- `src/data/yard.js`: top-level yard collection assembly. +- `src/main.js`: Leaflet rendering, legend generation, and popup rendering. + +## Key decisions + +- Geometry is authored in JavaScript, not GeoJSON, so yard data can mix exact coordinates with local offset expressions. +- Styling is defined by feature type rather than repeated inline for each feature. +- Local offsets rotate per anchor, not globally, so future anchors can carry different orientations. +- Grouping is stored in the data model now so later show/hide behavior can be implemented without restructuring features. +- The example GPKG data was used as a one-time source to seed JS-authored geometry, not as a runtime dependency. + +## GeoPackage notes + +- `buildings.gpkg` was used to seed most sample geometry. +- The `lot_boundary` layer had mixed or incorrect CRS metadata. +- The usable lot polygon was `fid = 2`, and it had to be treated as `EPSG:26914` during one-time extraction before converting to JS coordinates. + +## Validation + +Useful local checks: + +```bash +node -e "import('./src/data/yard.js').then(({ yardGeoJSON }) => console.log(yardGeoJSON.features.length))" +``` + +```bash +python3 -m http.server 4173 +``` + +The first check validates that the authored yard data still imports and exports cleanly. The second runs the static site locally. + +## Run locally + +Because the app uses ES modules, serve it over HTTP instead of opening `index.html` directly. + +```bash +python3 -m http.server 4173 +``` + +Then open . + +## Zoom behavior + +The yard overlays are vector data, so they stay sharp at any zoom level the map allows. +The current basemap is still raster OSM tiles, so above zoom 19 Leaflet reuses and scales those tiles instead of fetching higher-resolution imagery. + +If you want a truly crisp basemap at arbitrary close zoom, you need a vector tile or self-hosted tile source rather than the standard public OSM raster tile endpoint. + +## Future direction + +- Add UI controls for toggling groups and possibly parent-linked feature clusters. +- Decide whether toggles should operate on feature groups, feature kinds, parent-child trees, or all three. +- If sharper basemap zoom is needed, replace the raster OSM background with a vector tile or self-hosted tile solution. +- If more data import is needed from GIS sources, prefer one-time extraction into JS-authored modules rather than making the app depend on runtime GIS tooling. diff --git a/buildings.gpkg b/buildings.gpkg new file mode 100644 index 0000000000000000000000000000000000000000..764100e6fbd2ecbcc4d1be6e8d34f4c35260cdc3 GIT binary patch literal 217088 zcmeI531A!5o&RSn*<;7Y}Kxqr5CoQ}5-$L(WDW#>QY$?02zc({anvq79 zWXCvRe@aH9cmLk!y*F>3or#wgAd0)N&GkPFYzz%&+*UjzvX`+d#R)2XC#0G zkN^@u0!RP}AOR$R1dsp{Kmtf$J_M|9gj9_O;LW@n-fFkN+v{53ZFwEM@yjdWy|{EI zQ)Q^Ut8{18;c^pr;0p;L0VIF~kN^@u0yzkrbltL|w1=rKdZ&-s*Xt2BZ4lNALosnG z5NR0{qoe+KBLuOo}oU6 z+tt65W@5H(?C9EL>*%t!cW-KK@9b)C@95mvVYLr?`&{mR@5n(jDcfu{ceQo2nXkN3 zCuCzs`$iHnTa`<=)=x_??H1w9Ea!-v$f6EwV|`4 zy}P~B*4@3KL(hh8TYI~{4I7d+xOO{sc>7(>gJufPXY&l1!92U$KIrf|+y~90VmKc4 zhkO%aG#Ci_!%(~?JT~eNiM}!aD4;i6ZJMw#e<&vUM1L$E43DQLlmo+&X#6k@m8&@+ z#-mV6n+}ieHkpTge?PRXgJ$fod%Qqf@^mIMbh_N$zALSc{a%ODBh`|03TRk!^JFyg z#vZ8AsL<^1QCd}KK9u?x^2dGgSWlbK9EyZ}ZREpo`7|w`j`Y}s=BaQHB25U*VSiv8 z@Wgtyg{MLxq50U6xOhAc{t;h59E$`4r^y%vkGEV!>O&!ps zhrNTsHIc9=*fuyxp9-33f7XkU>dR7SvMWm{e0I`uqyDgOr$0V5LHc%wx^MToJp%Z( z>GbY)Tbo`ZbhX*K^?TOF_Ki}=lJ@ST4XNJkjrzl}qhb^~BkkzgP7ci-j86qbUu4V| zi3Z1m8QnZ{7_@YEYdX7jAhfl1cXw~pL`WMAoo(c$6Jzm*L}q6MXtoW|sIO-@xV)Cu zO$>*j;|9ZGKo}Pz<5B3nqqoNR- zj>W}^gRjsRw}q)@>yD&K+2N1I#TZ;)Xd$m8Gf#dHQjz8;WOnsO$I6;vJe>$8YL&}B z)FB|ogQH|DE&t8`l`Fq=JseQFw%Mv&59Q0`KqNdab$PQ@xq4>IV#+)L7eG1YdOAvk z6#bYG3=08qG&tc8!I{iYF(DEb;)g{+VIP}3EJh>2KqY@A!&Q11{?1AdUt0Cus(4jR z<>zut_!x`?kN^@u0!RP}AOR$R1dsp{Kmvq-zo@*SwxP1e$Z=dzC=&M_iojyoADtc( z;k%xw7*qSTx3zCHn(+e?zFXbwrx7_iuWVGw|l<1LVyhI)%UbtMb3Lq z+sT<43fZ%VsrBTWh-A)Vl1rU++vd)$&9<)AuFkFl_m6a6dq4c)`L^ER z_qIV+;GR+3j|<>l{+lkNY%@5RA}O;Z5|VRT#x`PeTUYmipxI+6udS`E>@gUC=%oLs zI04_V(zMjAO}?RPbNj~D?u~5+ijVkQO#?vCb{mun6+H0k*uZ zwoc+@C^|G148a#U<4I&m53P0W8#lLiw03QIy2_OL^fCP{L5A%+Voc6c>;<6I@<9}IMv0-hQ{OfPxr_dC5o^SWYgZS6XegCu51 zLkIeu4v*K}?}WV$YK@RyDuhI#F_PH}2Ch~LrVsIF#JISKYmj27pX^VI#jjY$a&=Oy z z6jdARYE!y@)Eoc)f00@ovqb_(00|%gB!C2v01`j~NB{{S0VGf`fyMp%e`a~X24Mmu zfCP{L5poKz{$n_5XR)U}!25Kmter2_OL^fCP{L5|d62^-NB{{S0VIF~kN^@u0!RP}AOR$R1kN7<`1}9)(^hCH5Wn2;()a5*#c zXcQp6;IQxL6Wp$Ski56wDR>+MjvZdXZSVIun(W(MZm(5nl#LW3VIl566cUBT40{?I zg`>ep2&xhjMkC=^Jn9dI7RaA+3pjnP8I#HM6y{ z#N=sVnP4~|9*XHPT+c?O@I2op?Tei@{VsQL$T*wHP<^!pl zWC_cba58ut=8faNSnwKA*y$Q36-t^U2Ttn;?r=F_10Fk=B1|6f#p4sgkh_1-?j8~L zI7ZYAtg7p_#i~j+2FWHSd@5RG%IWWQrsrT0+>TvvU3czqcx2o#E5yZcJf=czvCLky zj5Bq1vM1#%)7!-t6EiO6#IJnL)+901wb8byy3W>EH0BEiG8I9+~4)4J6k$vvFdR{&>(I@S|6r>(i z_mW{}{}sb3I8ky<6Y1FsyzSLh*vI!E>{_MYA4V zceFVw1gjXoj|?Sk9BVKD7yaN^P>jj}a__YU#Mo#wI2jMZnnIdzY1JDWts#Fb?i+>W z!8pusu$1xk52j`~fkteCHO5#xI3YGQHjK11OtdrvybXPu8wNKwj2&pSHijd|8Z8#9 z`busU)K_rpgzCZ#L8=RO+PsiC7@}l2keOpXSJ&jlwcA)?8f=`ig=s4&1A#h=yAAm# zl{-F7YJ^+&QgZt~V_>DZch-{X9BK1_)c4c;iZZ)OS`2yi(7Bh&az#~N6RnZxxIY}c z2Bst0Gm=+Xbr1}kNHhRbmG1N%5XXYypp->gcTDehmdesHhG%}l6g4qFsk)X(;yh2|2*>bRySAO&TT1UO4P`(>Vl2pFfaqWU@6&P{cC$Aos>Z=`%5&46a zjZSh+2z8uk&mK0>C0&K0WJ6J3a=T9H#)<4a>YMaOVIs*9Y|>nt%sFD4o5B&8og&HG zf7NQpDwjthSQ4u*;!M50Y@%^)6G`N(MUucZd74M!&S@N(Y%Ph`)pDl(em2oOw^`8$ zAkS)-6k469VNzsslXTLhMl26UfY^ncsi%icY?;%vY(~-V>-P4$y~Fka!7DA=vo@z$ z81}%fvVxRdQ!pS+^T(wboxBIb)6(Z@OHWOS>5Aq#=UeeOEK*}I_r#R@sg%d+#IXxF zQ*$#r+aT3SnjBNXY0gy|q-lW}_f#3{6l*eLy`_n2e$#$i5gUzU+<~c9BIBYjJ}L^C zD+tZ*IBpr09*Jpy6-u9aXWs_m6U>=%^ zPDWy)S-M5AFbuov{F~y>HH&8snB-}4OSWlJ{SDQ;fBn$OL}!{*$6DYw`1Ah-L}SFn zR80B>WIPJ%KUfl_{Rl#Cb)_FdG>I-x{uHu%jSROYFSzsl_E}xHa!;6qyTm_-s7(_V z2=hR7UOiR*qIH2iRg*~)X6lAcu9xzi?o!>*X^bcgu41{Wr>@Scrz%_))>Ac^B;lSD zJyqedK$r(s$;-IzY{Oo;6;~Eu3tW3>a!KM$^;Jz_OLke+T9N%t40v#xE{tdh|@S z{fAfIvgJuQSBA!JTlGzk{Vw*7i>l$w8X!3fM==~U@{e!++l#9qaNQTJJwJj(G&0TP z9e+IYFv(=o352wM-mZ3F*Cc@L=AfT<6pY>CP;JmgHQH<9?m82-h0DJ z$m{tV`YzQNR1OBw$UlAbe^!vdliUBY|5ivuBgYNz|Hr$)y)PKLm>BezFF$+Yv*2F! zxzirvriH|Q`q)=UAwM7Pzv^SELVNDrJai|xuQpYFgoO9qx|R7Taew2rKZNtwzBVE8ThqAh3mTI+Fo{N9bLvY{3*Ghy z%eNncL^LwZWZysCv63WiZah^=y4?LQUG{fUh3H=^4v=25WB*-J*L!x)^7oNOqlKpD z-xryRiDdm>!heF{pW}bYKf!;S|1vo6g#?fQ5N_L`V#Q@fgNY@7K^!Y9GZ@yEjY)r?SW#rC zWhebd#R>QeVq+z1sAH3VPG}_Sf39*T!+)6XsQPYIxT>P^liYN`r4MO*Qu6b^m!}k!8iFiKS|o>a6CS_dHwoh$Bwl^lu7^S5%|@( z6`t%~ADa|MZS8IA>r)S(N$HM7K^v$g5)MuKKoEfxBQ5Y2qkyZ5OV&#Dkd}>$QGySi z_)nIw7zG?Ml+=;}=yG>4$`~suX&_~&*W!y&&L~?_N6JYq))%7?qW{lT{fXg!#E5jkN^@u0!RP}AOR$R1dsp{Kmz9x0ZTD_RipmVJlmq0t1pJH zD&$|&v&AX7xEQ`LVdbCMvqdVFGm;`@i(|Z`7{0bh{z#uKn&Hx7_~t?LM*!KP7F|*d zUmld@{O&+{{-1w_;a}okfM)=nIggqWO+^Ao00|%gB!C2v01`j~NB{{S0VIF~bO;!X z1_quBAa6zF4Kyjr$oiixZ(-m+zK{SCKmter2_OL^fCP{L5>OMkZiqE9H(m^0WEU~= zV*Ad7UhF#)zI*pALm!0W9@(2I>N_)eN#B{P4}srJa8OMx1H}JK-o7!2=EFU3kM81x^{43HCyWJ{0{VA&!#oI7Vq z8@Za1I(8OhPJ-j(WJ7uBv1Ghk2*{?BKhenFx5`7eM|C>LGxzke=s*aho%&hdO1)yl24~oQi16Q-Bj-8p% z49}!pslg?8lVm_fcaX<*GCVyYt-O9ekcMO=6wn=!awky|?sB^Z=@82-Od8zK8G-?5 zeb>OwR%wXI{lD#Eu4WMW|66j^RNm%xLo)aLtE!}=b){R|YWuuY*Q9O2x*oyl*e4q* zU3}8Uwb1X8u1hX=9+#v-A;DFwDQSu|Wr#dZW{2t}WT$%aL)I~GWF2Q08C<^$yp|UV zPSd;SfYzS~l2DREVku{V^=5e#yeYpXZBJTX=qPAUZ_-^tY7R}G2hzsGQVt1C%fh@l zE3A`BeiWd;3X$6z=JA$GC8Z+hK=0MX!DPfP>ET>KpiEy?t_)=B98Y zAciC4?%P4u2t)4vLA!fI*y9+n%27xJOQN=!GYt;1iI$wUlK693O2TXMx06Jl)5?H9 z?zbe?UCNnUE;eDyX zXOg}BZtt*tAdi+d3&Wm%XRjb-+7t{}g^6JJIK2ChllNeFTKYU~Nt8BmriKPK;gRf9 zcN_KRwOTUjAhXaUNH>jo7zF#U*VXTY)PoKutQVCvqg6e#4Y>wJdR(_^3g`FEaL17~gAV1;NKg9|4I_k$TXAY|9s%jTOj;H4?kW`GN+lQr^{|IUNgY|R!BSE*%B?$d~?O!|+C6Cmi3Qsa=tuWU4csyw7A_tD^t zbW~e~ie_cV1<{m>fL~I!3Y5O)7SKA|+BejYWEGZD&ndc@tLcZ<;c~Q&EMP-T&%1H5 z>fX?tUM)FATHd(;9fUnLoChNpO+1GPB#yRAI;b;m(+pI#^h~mAE0Ub*uq*>s9VI=pGUoLnA!+5AoB6w;zZ-O1_66ZlsPmfWZW#I%1pM-TJ8Pz7}anxw{QIR%%RmLn?EuB2? zZfNdm8W{>-&)kcdX6nH+xh?RFCW(8!Hztg1}Y( z?4L>ai|-qL*a+d_^7}tY+*aX3Jtg42{r7b?;(obm_=cZC{?y2HdsKROq;9U$^+!qZ zJJ`;8f`k@BCGIqwY2?yB-WMcddFi&%On&|T+iM7zbiKUv^fS|-hj;QQ+PuD4v){mb`& zd;fob`>!OtZq4tmdoQ>TzIXd-lGz7-a^LRvfqQFb`3B{_@9O?j!NLH?MykanF2s@?Gx-cT3O9WyF2y<6HI<_t?Kb`aCKB zqV4Z~8;Ku0wc`kJkFS1N{6}!V{Mp&##Qlk{|3m)=z+L@o*IS9Zx%=O)`5?GQmvt2r z_w~PR`2mUl$XoUo5%CHw!v{KkGX)Q34u&H^cEs#UJ$l zCYOQ!-y!h3Q>FiB=sOes3xsup->qPAr^ zKBr)oB4+59WQ5xx1Op)Zmy`h9BFwFPlAg!Xsr=OROHDUBn!D-R zC94Z*y4-?3`VG0rNgzb)AP*gi#C>!it&AwW8wnm|>oD>m2-tr(FHVCy$K#BI8jgJ{$+R*O=gOcm<73a3R*ZTLhaRkMU6Cm>6x6 z|hh?Rg!YjJwh_IMjKE!=n}2Un|xu^jhVM9V5x&MZQaTy>av@ak_1dEiUc&| zVN#xAfE7DAlikiH>a&}oQUXk~MGCn%4`cGy04&|XnL0b!L`PP0^!U@xt*id0wK;F; zzXY?LGnq_m!Y7#`)7wL;P3zQ_-X@_AOR$R1dsp{ zKmter2_OL^fCP{L5;)HY;QIeOYcMny2_OL^fCP{L5x`JK>|ns2_OL^fCP{L5_s(sNa_E*_Uno@L;^_QHBSJ4|9{OlF;)->AOR$R1dsp{ zKmter2_OL^fCP{LL)QPxnJ`oJ^Q!X7)fKhn?<+4^7BBlv*#P(RlELCj4Z$J@`$mY6 z$7@E|%hmL*WoLsL5g19mASS6{B&H8=H6Dn;D>0OlPgem+PAI=q`Gur5U>+%{f{;o% zNzUxm(oK>}Q?#QJ`$s@FZY?`=FdH4BA*P5QWvR4Em_i1sNmg-n&P7&3%4mY@ z%9tyLr9OUgPab_-mc}d$QJ&(Mv4RDo3h(fAQgI7z$IyU%hXa%xy)MD5J4DUG(skRz zUTNAn@uoT3M4o`DL_CrlpBXqrr_l!RVJ((hi+ z!mWBGuw9oi<4v_bFED-+#_q5!`)eMd|Zs`8mV5IV6nfCP{L5;0&DWeH;3|7wp|;IB?qqkHeWQ0vn8Q&b!lzVOR; zs1neOKEI*#h95%sJxBlYu2T?BGx8jcMB~C_aP){6m)HL(IGbl0|l4lmZ3Ec!bG8zh{`N?Q#3zL-tC7*rruQjIbdqm= z)lyQ>dJPW|rh*Gpq}25)K;Ex#B`-yiWS84DNaee<)yjs2ini!WfTicLWC~#Ae_*aS zTSHgMC$TcfndfK_+O%U?6>=6v^uS23%L#+j31e3t(3V8( zFcIx3%}=XHY8>f}n(6NE?RB_Gx06(2^d*j@6tu6Zq-+%b?T%hhTT%M(_~emsUt~P$ z8;!svBOH$j9*0*L6aDe2s0f}@;keL~=FUs1NGeQ`Dw?@a)Kj;SqNFZ1idK!}Tgb57 zK&!g&VYxunO>Ko#19EP4o0*zX8P90$t5$o#cybFiN?B$|0WGp#V^&rg*-eLY=WN9x ziOxw!ZaTJt>EzaGoFARSFYxEdb$VvhFWoqtn3}6OWG%4*S|h7WqZsZUC8*=_=|C!-BZuPvz?)&;j~ zUc!Bn*;KD^^;NQ-=6Y3&Xa}4|c2qa0jWV!rA8_d32Nx7XbqvaZo1<} z8#V6{Q(5)j@DMiVAb&yCmdgLCycfLig#?fQ5 zn_S*DqWuQ1?WKBoEWh>nx=%qKrpE96aYgPtz=TXYzWQ>n@#B!k9Z&UKkcS;5dU^cR z_J-&F1@id%Usr9-oyWE!$fJd2*J*fM{`EH<4M85?uwCL?4RzTbnF@(V{n3Ch5DW*z zu%V>At=-nrwy~vcQ#V_}?CNA$O$MLe_s#!*J!DYxe9;4|AcNihNq<<3iGpKdGAhPm zf+I9h!tqx!7s*KV-tEPZF*KDX0|8^H-{N=W%2Xfbre}pk^G{`m7w)lzvdXsQ9 z7VU758aKcGF+~<#y7@j-z699DY6zm-0}BF1>2x@FRc! zU&j9c^#Ax5_~-a%_}}us;Gg85055zY0VIF~kN^@u0!RP}AOR$R1dsp{KmzA30Ye$+ z`WZ^e8%N$s$XhXaGmSv1dsp{Kmter2_OL^a1Ibi>Hn1}*06nN zLKARAcHc5|3mms9{zZLfjvRs`0DiZ_afjk>0Ke*rekly_Q=S>xKl701`j~NB{{S0VIF~kN^@u0!Tn6K-T}o z6;li!uKID+y2|U|BfgLT5=tZA}^R;zvy@x#Byy~{lXUNmh@Q2Fqq&ysCwQ||Ln}_a%NRy_@kB~s? zC#^$wfcr}i-H{+}n%QKsf>d+!Y-Jl*~wfBf(<|8%0-Sj^0A(PaR;_BK+x-f-s2pZy!Dj*`U{&v$%7*Den1qLKfD`QaOg9qHxJ zOlajSt6u+JVmHk+{H^Psd<6=-{yWz_MhY3Jy!Ph*0e8z6?v!Ap=fB-QdPE5Lr-e{3 zG@V#pM2N1XmIrQsY1`wF_@Sqs*#37o?_P83M+ppSoQ5ckeB{37mq?`JD@X47cU2;q zN$;)~UnJq_mZYce1@!_koZwkvl8Ksh=U4aN_XOnnsXHPccnZ$Hx~SPk>bY{vI!JQW zm{eqVX!TE2k!Yf)p0{lwi5^TeedoszP7~A2pWgHb2gxiweV-UT6qDBf{BKpy|3kCk z3ke_rB!C2v01`j~NB{{S0VIF~kN^@m9|_>||L0??q0LAD2_OL^fCP{L5yx{r&@Ymq@D?B@Y8yugKy%~7^Km3WlGm#&F-x)aWRs7-k|HwmqXC~Kz-`#M0 zR`G}D|090^e=GQXOyy6W|6d8m$H9;20^Fp;#}g7j0!U!N1S(k%{8i0nrtE0xk4oE0 z&J?qTud~0adZ_aI759~YoY}nWhHOR5tU1cn_!>%QJCuD;>2|2Pn`VY0ao-`>^E%*< zPOCrDT@}-}-_Dtd&IX+7e9gkEvk#}bOl3RHWhUnR*`O_%|Lno5VQ19((o=()&4pFl zvM2X#^~u|Xcc{YBTPge2t~4=i9@Wm_s{Asx#9o(cPwc{XeO9*I-Q{)-((O!D79?w{ z+DlxsKQ`Ui)h)OkLj(354x!)abhbypgW>(M&$t+w z5aZElv&AZq0RQpK06D=(wgedgmMsG8*n70T$Ou9Tjy%( z1#T6uDmrH)lTFYq5eePNV7};VsuBuDmwQ)qRqh3&D}0okSKWM;33D~B`qGnYbGMZ| zfv8{>&{T3-dI_V5V2fu}c1qhU`o`(1B30da$*wesOC~sz%f%+PsJ1TFvr)6-^c)d7 zX}hTRot`7UxruD~9FCC7v7^`F7KYsYgLe0bu*WfCo%Bb=aGXT2BrXVXragPuL~|}f zN%T1;G!opAw~-|FoF)eRala*T(GkuxILIc{J9g_6_VD(+&C7R(YPe6CUXpv2 znKj4s?e)yIOc zsM6_B(S$DeRy!{!sv?t2;`&TJIm`QU!9Ys|Ug-K#ECe4vE z)4AtJx$CI%NQW`UWl>H`DX7MaDm!J4jD!NM=H$(<05>Uub9i7tS)ncPKAqhCc1PvA z-?H3Qme)2LR7Mt1XC*PM^x0b5Eba|&zf)*(4eV4FBtmj+BCi}!|8GgAIV= + + + + + Yard Map + + + + +
+ +
+
+
+
+ + + + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..6f987e6 --- /dev/null +++ b/package.json @@ -0,0 +1,5 @@ +{ + "name": "yardmap", + "private": true, + "type": "module" +} \ No newline at end of file diff --git a/src/data/anchors.js b/src/data/anchors.js new file mode 100644 index 0000000..f7a197b --- /dev/null +++ b/src/data/anchors.js @@ -0,0 +1,7 @@ +import { anchor } from "../lib/geometry.js"; +import { lotAssumedNorthClockwiseDegrees } from "./settings.js"; + +export const topLeftLotCorner = anchor(-100.89895832874616, 46.82679973173392, { + assumedNorthClockwiseDegrees: lotAssumedNorthClockwiseDegrees, +}); +export const lotCorner = topLeftLotCorner; \ No newline at end of file diff --git a/src/data/boundaries.js b/src/data/boundaries.js new file mode 100644 index 0000000..ae61595 --- /dev/null +++ b/src/data/boundaries.js @@ -0,0 +1,19 @@ +import { Fence, LotBoundary } from "../lib/yard-features.js"; + +const lotBoundary = new LotBoundary().trace([ + [-100.89895832874616, 46.82679973173392], + [-100.89839320971627, 46.8268766687031], + [-100.89835104203355, 46.82673956300183], + [-100.89891868391628, 46.82666533836659], +]); + +const picketFence = new Fence("Short picket fence", { + fenceType: "Short picket", +}).trace([ + [-100.89860584453643, 46.82671142438681], + [-100.89891903869233, 46.82666876370629], + [-100.89895733199393, 46.82679939652137], + [-100.89883731628171, 46.82681591825169], +]); + +export const boundaryFeatures = [lotBoundary, picketFence]; \ No newline at end of file diff --git a/src/data/buildings.js b/src/data/buildings.js new file mode 100644 index 0000000..9a7b69d --- /dev/null +++ b/src/data/buildings.js @@ -0,0 +1,42 @@ +import { Deck, defineGroup, Garage, House, Porch } from "../lib/yard-features.js"; + +export const housePartsGroup = defineGroup("group:house-parts", "House Parts"); + +const house = new House().withId("feature:house-main").trace([ + [-100.89883475400929, 46.826808023423844], + [-100.8986668040935, 46.826830709973976], + [-100.89866031675769, 46.826807530237865], + [-100.89869131180652, 46.82680358474988], + [-100.89867545387456, 46.82675525249843], + [-100.89881168792644, 46.82673552503637], +]); + +const garage = new Garage().trace([ + [-100.89850173743805, 46.82679569377302], + [-100.89839289435957, 46.82681098253964], + [-100.89837054909181, 46.82674095008918], + [-100.8984801129854, 46.826727140862815], +]); + +const porch = new Porch().childOf(house).inGroup(housePartsGroup).trace([ + [-100.89886286579778, 46.8268040779359], + [-100.8988346639074, 46.8268079617756], + [-100.8988116428755, 46.8267355250364], + [-100.89875969913811, 46.826743015307995], + [-100.89875316675139, 46.826722208995406], + [-100.8988050654378, 46.82671481119343], + [-100.89881515684904, 46.8267162907539], + [-100.89882614927917, 46.8267201129516], + [-100.89883678130174, 46.82672738745619], + [-100.89884281812813, 46.82673191860895], +]); + +const deck = new Deck().childOf(house).inGroup(housePartsGroup).trace([ + [-100.89869090634811, 46.826803553925714], + [-100.89864495438617, 46.826809472157606], + [-100.89863017767685, 46.8267616330979], + [-100.89867522861992, 46.82675522167426], +]); + +export const buildingFeatures = [house, garage, porch, deck]; +export const buildingGroups = [housePartsGroup]; \ No newline at end of file diff --git a/src/data/irrigation.js b/src/data/irrigation.js new file mode 100644 index 0000000..4c3e0d5 --- /dev/null +++ b/src/data/irrigation.js @@ -0,0 +1,8 @@ +import { east, ft, offset, south } from "../lib/geometry.js"; +import { Sprinkler } from "../lib/yard-features.js"; +import { lotCorner } from "./anchors.js"; + +export const irrigationFeatures = [ + new Sprinkler("Front bed sprinkler").add(offset(lotCorner), south(ft(15)), east(ft(10))), + new Sprinkler("Garage side sprinkler").add(offset(lotCorner), south(ft(92)), east(ft(40))), +]; \ No newline at end of file diff --git a/src/data/pavement.js b/src/data/pavement.js new file mode 100644 index 0000000..4008cdd --- /dev/null +++ b/src/data/pavement.js @@ -0,0 +1,51 @@ +import { Driveway, Walkway } from "../lib/yard-features.js"; + +const frontWalk = new Walkway("Front walk").trace([ + [-100.89894913835376, 46.82676927748655], + [-100.89887345276941, 46.82677975769509], + [-100.89887849847504, 46.82679985503042], + [-100.89887651623354, 46.82680417040822], + [-100.8988709299166, 46.826808732378645], + [-100.89886462278457, 46.82681082841896], + [-100.89883254651308, 46.82681514379587], + [-100.89883047416976, 46.826808562845976], + [-100.89886426237707, 46.82680492559929], + [-100.89886660502609, 46.826802891207], + [-100.89886716816289, 46.82679990126662], + [-100.89886270811955, 46.82678063618305], + [-100.89885982485919, 46.82678254727965], + [-100.89885694159882, 46.82678341035552], + [-100.89885243650448, 46.826766457077134], + [-100.89885784261767, 46.82676596389077], + [-100.89886288832331, 46.82676682696687], + [-100.89886694290817, 46.82676836817423], + [-100.8989459622624, 46.826758196205], +]); + +const driveway = new Driveway().trace([ + [-100.89851482473702, 46.82685818966149], + [-100.8984941013032, 46.826796541436565], + [-100.89837336477572, 46.82681355635369], + [-100.89839320971627, 46.8268766687031], +]); + +const garageWalk = new Walkway("Garage walk").trace([ + [-100.89862628077022, 46.82677780036217], + [-100.8986050167251, 46.82677755376906], + [-100.89853023215954, 46.82678544474858], + [-100.89851239198609, 46.82679937725647], + [-100.89850049853712, 46.82681602228328], + [-100.8984941013032, 46.826796541436565], + [-100.89850173743805, 46.82679569377302], + [-100.89849635385036, 46.82677854014156], + [-100.89862267669479, 46.82676559400101], +]); + +const stepPad = new Walkway("Step pad").trace([ + [-100.89837579752665, 46.82675708653554], + [-100.89835705633432, 46.826759675764286], + [-100.89835309185132, 46.82674722280572], + [-100.89837111222856, 46.82674500346631], +]); + +export const pavementFeatures = [frontWalk, driveway, garageWalk, stepPad]; \ No newline at end of file diff --git a/src/data/plants.js b/src/data/plants.js new file mode 100644 index 0000000..d7fa3df --- /dev/null +++ b/src/data/plants.js @@ -0,0 +1,106 @@ +import { defineGroup, FlowerBed, Tree } from "../lib/yard-features.js"; +import { east, ft, lngLat, offset } from "../lib/geometry.js"; +import { topLeftLotCorner } from "./anchors.js"; + +export const juniperTreesGroup = defineGroup("group:juniper-trees", "Juniper Trees"); + +const flowerBeds = [ + new FlowerBed("Herbs").trace([ + [-100.89863446807426, 46.82677625460535], + [-100.89862628077022, 46.82677780036216], + [-100.89863677743469, 46.82681615158733], + [-100.89864678466313, 46.826814966528865], + ]), + new FlowerBed("Garden").trace([ + [-100.89865910125202, 46.82684024777001], + [-100.89865409763779, 46.82682773882406], + [-100.89853785983037, 46.82684209119338], + [-100.89854344078469, 46.82685618021274], + ]), + new FlowerBed("Back day lily").trace([ + [-100.89854344078469, 46.82685618021274], + [-100.89852458100802, 46.82685815530862], + [-100.89850764569832, 46.82680482769446], + [-100.89851239198609, 46.82679937725647], + [-100.89851803782018, 46.82680087749872], + [-100.89852554324152, 46.826804564348095], + ]), + new FlowerBed("Coneflowers").trace([ + [-100.89852458100802, 46.82685815530862], + [-100.89851482473702, 46.82685818966149], + [-100.89850049853712, 46.82681602228328], + [-100.89850764569832, 46.82680482769446], + ]), + new FlowerBed("Pathway border").trace([ + [-100.89881515684904, 46.82671629075389], + [-100.89881830278543, 46.82671252471132], + [-100.89882138193263, 46.82670673108083], + [-100.89881445385141, 46.8266828981853], + [-100.89877269291729, 46.82668908683872], + [-100.89877211557717, 46.8266923786754], + [-100.89876884398328, 46.82669501214458], + [-100.89876326302891, 46.82669764561363], + [-100.89875979898829, 46.82670304422476], + [-100.89875979898829, 46.826708574508785], + [-100.89876461015585, 46.826720556788906], + [-100.8988050654378, 46.82671481119343], + ]), +]; + +const trees = [ + new Tree("Japanese Empress Elm", { + species: "Japanese Empress Elm", + canopyDiameterMeters: 6, + heightMeters: 7, + }).add(lngLat(-100.89889018162828, 46.82675624026659)), + new Tree("Boulevard Linden", { + species: "Boulevard Linden", + canopyDiameterMeters: 3, + heightMeters: 6, + plantedDate: "2021-06-08", + }).add(lngLat(-100.8990003556445, 46.826776648309774)), + new Tree("Boulevard Linden 2", { + species: "Boulevard Linden", + canopyDiameterMeters: 3, + heightMeters: 6, + }).add(lngLat(-100.89898269567485, 46.826709328342595)), + new Tree("Evergreen", { + species: "Evergreen", + canopyDiameterMeters: 8, + heightMeters: 15, + }).add(lngLat(-100.89888268258135, 46.826680107011924)), + new Tree("Medora Juniper 1", { + species: "Medora Juniper", + canopyDiameterMeters: 1.5, + heightMeters: 2.5, + }).inGroup(juniperTreesGroup).add(lngLat(-100.89880933964611, 46.826705259550664)), + new Tree("Medora Juniper 2", { + species: "Medora Juniper", + canopyDiameterMeters: 1.5, + heightMeters: 2.5, + }).inGroup(juniperTreesGroup).add(lngLat(-100.89877004677517, 46.82670396593881)), + new Tree("Medora Juniper 3", { + species: "Medora Juniper", + canopyDiameterMeters: 1.5, + heightMeters: 2.5, + }).inGroup(juniperTreesGroup).add(lngLat(-100.898799106227, 46.82669830398086)), + new Tree("Medora Juniper 4", { + species: "Medora Juniper", + canopyDiameterMeters: 1.5, + heightMeters: 2.5, + }).inGroup(juniperTreesGroup).add(lngLat(-100.89878871410515, 46.82669251034885)), + new Tree("Medora Juniper 5", { + species: "Medora Juniper", + canopyDiameterMeters: 1.5, + heightMeters: 2.5, + }).inGroup(juniperTreesGroup).add(lngLat(-100.8987812086838, 46.82671120797719)), + new Tree("Offset Test Tree", { + species: "Test tree", + canopyDiameterMeters: 4, + heightMeters: 5, + note: "70 feet east of top-left lot corner using assumedNorth", + }).add(offset(topLeftLotCorner), east(ft(70))), +]; + +export const plantFeatures = [...flowerBeds, ...trees]; +export const plantGroups = [juniperTreesGroup]; \ No newline at end of file diff --git a/src/data/settings.js b/src/data/settings.js new file mode 100644 index 0000000..0700634 --- /dev/null +++ b/src/data/settings.js @@ -0,0 +1 @@ +export const lotAssumedNorthClockwiseDegrees = -11.25369719051117; \ No newline at end of file diff --git a/src/data/yard.js b/src/data/yard.js new file mode 100644 index 0000000..88f411b --- /dev/null +++ b/src/data/yard.js @@ -0,0 +1,20 @@ +import { collectYardData } from "../lib/yard-features.js"; +import { boundaryFeatures } from "./boundaries.js"; +import { buildingFeatures, buildingGroups } from "./buildings.js"; +import { irrigationFeatures } from "./irrigation.js"; +import { pavementFeatures } from "./pavement.js"; +import { plantFeatures, plantGroups } from "./plants.js"; + +export const yardGeoJSON = collectYardData( + [ + ...boundaryFeatures, + ...buildingFeatures, + ...pavementFeatures, + ...plantFeatures, + ...irrigationFeatures, + ], + [ + ...buildingGroups, + ...plantGroups, + ], +); \ No newline at end of file diff --git a/src/lib/feature-types.js b/src/lib/feature-types.js new file mode 100644 index 0000000..1bbb191 --- /dev/null +++ b/src/lib/feature-types.js @@ -0,0 +1,125 @@ +const treeRadiusRange = { + min: 5, + max: 14, +}; + +function clamp(value, min, max) { + return Math.min(max, Math.max(min, value)); +} + +export const featureTypes = { + lotBoundary: { + label: "Lot Boundary", + style: { + color: "#7b684d", + weight: 2, + dashArray: "8 5", + fillColor: "#dccaab", + fillOpacity: 0.08, + }, + }, + fence: { + label: "Fence", + style: { + color: "#705033", + weight: 4, + opacity: 0.95, + }, + }, + house: { + label: "House", + style: { + color: "#8d5a35", + weight: 2, + fillColor: "#c98d58", + fillOpacity: 0.35, + }, + }, + garage: { + label: "Garage", + style: { + color: "#60636b", + weight: 2, + fillColor: "#a4acb9", + fillOpacity: 0.45, + }, + }, + porch: { + label: "Porch", + style: { + color: "#aa7148", + weight: 2, + fillColor: "#deb588", + fillOpacity: 0.38, + }, + }, + deck: { + label: "Deck", + style: { + color: "#7f5a3c", + weight: 2, + fillColor: "#b98c66", + fillOpacity: 0.32, + }, + }, + walkway: { + label: "Walkway", + style: { + color: "#b8b3a6", + weight: 2, + fillColor: "#d6d1c3", + fillOpacity: 0.75, + }, + }, + driveway: { + label: "Driveway", + style: { + color: "#9c9890", + weight: 2, + fillColor: "#bbb7af", + fillOpacity: 0.8, + }, + }, + flowerBed: { + label: "Flower Bed", + style: { + color: "#7f7f31", + weight: 2, + fillColor: "#bfc97b", + fillOpacity: 0.38, + }, + }, + tree: { + label: "Tree", + style: (details) => ({ + color: "#2f6b3d", + weight: 2, + radius: clamp(4 + (details.canopyDiameterMeters ?? 2), treeRadiusRange.min, treeRadiusRange.max), + fillColor: "#5c9e64", + fillOpacity: 0.85, + }), + }, + sprinkler: { + label: "Sprinkler", + style: { + color: "#2d7dd2", + radius: 5, + fillColor: "#88bdf2", + fillOpacity: 0.95, + }, + }, +}; + +export function getFeatureType(kind) { + const definition = featureTypes[kind]; + if (!definition) { + throw new Error(`Unknown feature type: ${kind}`); + } + + return definition; +} + +export function resolveFeatureStyle(kind, details = {}) { + const { style } = getFeatureType(kind); + return typeof style === "function" ? style(details) : style; +} \ No newline at end of file diff --git a/src/lib/geometry.js b/src/lib/geometry.js new file mode 100644 index 0000000..854b01e --- /dev/null +++ b/src/lib/geometry.js @@ -0,0 +1,204 @@ +const FEET_PER_METER = 3.280839895; +const METERS_PER_FOOT = 1 / FEET_PER_METER; +const EARTH_METERS_PER_DEGREE_LAT = 111320; + +function toRadians(value) { + return (value * Math.PI) / 180; +} + +function rotateLocalToTrueNorthEast(northMeters, eastMeters, assumedNorthClockwiseDegrees = 0) { + const clockwiseRadians = toRadians(assumedNorthClockwiseDegrees); + const trueNorthMeters = + northMeters * Math.cos(clockwiseRadians) - eastMeters * Math.sin(clockwiseRadians); + const trueEastMeters = + northMeters * Math.sin(clockwiseRadians) + eastMeters * Math.cos(clockwiseRadians); + + return { + northMeters: trueNorthMeters, + eastMeters: trueEastMeters, + }; +} + +export function point(lat, lon) { + return { lat, lon }; +} + +export function lngLat(lon, lat) { + return point(lat, lon); +} + +export function anchor(lon, lat, options = {}) { + return { + ...lngLat(lon, lat), + ...options, + }; +} + +export function pointsFromLngLat(coordinates) { + return coordinates.map(([lon, lat]) => lngLat(lon, lat)); +} + +export function ft(value) { + return value * METERS_PER_FOOT; +} + +function isPoint(value) { + return Boolean(value) && typeof value.lat === "number" && typeof value.lon === "number"; +} + +function isMove(value) { + return ( + Boolean(value) && + typeof value.northMeters === "number" && + typeof value.eastMeters === "number" + ); +} + +function isOffsetAnchor(value) { + return Boolean(value) && value.kind === "offset-anchor" && isPoint(value.origin); +} + +function translate(origin, northMeters, eastMeters) { + const latDelta = northMeters / EARTH_METERS_PER_DEGREE_LAT; + const lonDelta = eastMeters / (EARTH_METERS_PER_DEGREE_LAT * Math.cos(toRadians(origin.lat))); + + return point(origin.lat + latDelta, origin.lon + lonDelta); +} + +export function offset(origin, ...moves) { + if (moves.length === 0) { + return { kind: "offset-anchor", origin }; + } + + const net = moves.reduce( + (accumulator, move) => ({ + northMeters: accumulator.northMeters + move.northMeters, + eastMeters: accumulator.eastMeters + move.eastMeters, + }), + { northMeters: 0, eastMeters: 0 }, + ); + + const adjusted = rotateLocalToTrueNorthEast( + net.northMeters, + net.eastMeters, + origin.assumedNorthClockwiseDegrees ?? 0, + ); + + return translate(origin, adjusted.northMeters, adjusted.eastMeters); +} + +export function north(distanceMeters) { + return { northMeters: distanceMeters, eastMeters: 0 }; +} + +export function south(distanceMeters) { + return { northMeters: -distanceMeters, eastMeters: 0 }; +} + +export function east(distanceMeters) { + return { northMeters: 0, eastMeters: distanceMeters }; +} + +export function west(distanceMeters) { + return { northMeters: 0, eastMeters: -distanceMeters }; +} + +function buildFeature(type, geometryType, style = {}) { + const coordinates = []; + + return { + add(...values) { + coordinates.push(resolveCoordinate(values)); + return this; + }, + toGeoJSON() { + if (geometryType === "Point") { + const [first] = coordinates; + return { + type: "Feature", + properties: { type, style }, + geometry: { + type: "Point", + coordinates: [first.lon, first.lat], + }, + }; + } + + const ring = coordinates.map((coordinate) => [coordinate.lon, coordinate.lat]); + const geometry = + geometryType === "LineString" + ? { type: "LineString", coordinates: ring } + : { type: "Polygon", coordinates: [closeRing(ring)] }; + + return { + type: "Feature", + properties: { type, style }, + geometry, + }; + }, + }; +} + +function resolveCoordinate(values) { + if (values.length === 1 && isPoint(values[0])) { + return values[0]; + } + + const [first, ...rest] = values; + if (isOffsetAnchor(first) && rest.every(isMove)) { + return offset(first.origin, ...rest); + } + + throw new Error("add(...) expects a point, or offset(origin) followed by directional moves"); +} + +function closeRing(coordinates) { + if (coordinates.length === 0) { + return coordinates; + } + + const [firstLon, firstLat] = coordinates[0]; + const [lastLon, lastLat] = coordinates[coordinates.length - 1]; + if (firstLon === lastLon && firstLat === lastLat) { + return coordinates; + } + + return [...coordinates, coordinates[0]]; +} + +export function polygon(type, style) { + return buildFeature(type, "Polygon", style); +} + +export function path(type, style) { + return buildFeature(type, "LineString", style); +} + +export function marker(type, style) { + return buildFeature(type, "Point", style); +} + +export function sprinkler(name) { + return marker(name, { + color: "#2d7dd2", + radius: 5, + fillColor: "#88bdf2", + fillOpacity: 0.95, + }); +} + +export function tree(name) { + return marker(name, { + color: "#2f6b3d", + radius: 9, + fillColor: "#5c9e64", + fillOpacity: 0.85, + }); +} + +export function collectGeoJSON(...collections) { + return { + type: "FeatureCollection", + features: collections.flat().map((entry) => entry.toGeoJSON()), + }; +} \ No newline at end of file diff --git a/src/lib/yard-features.js b/src/lib/yard-features.js new file mode 100644 index 0000000..85ec4a7 --- /dev/null +++ b/src/lib/yard-features.js @@ -0,0 +1,279 @@ +import { collectGeoJSON, offset, pointsFromLngLat } from "./geometry.js"; +import { getFeatureType, resolveFeatureStyle } from "./feature-types.js"; + +class YardFeature { + constructor(kind, geometryType, name, details = {}) { + this.kind = kind; + this.geometryType = geometryType; + this.name = name ?? getFeatureType(kind).label; + this.id = buildFeatureId(kind, this.name); + this.details = { ...details }; + this.coordinates = []; + this.parentId = null; + this.groupIds = []; + } + + add(...values) { + this.coordinates.push(resolveCoordinate(values)); + return this; + } + + trace(coordinates) { + for (const coordinate of pointsFromLngLat(coordinates)) { + this.add(coordinate); + } + + return this; + } + + withDetails(details) { + Object.assign(this.details, details); + return this; + } + + withId(id) { + this.id = id; + return this; + } + + childOf(parent) { + this.parentId = resolveFeatureReference(parent); + return this; + } + + inGroup(...groups) { + this.groupIds.push(...groups.map(resolveGroupReference)); + this.groupIds = [...new Set(this.groupIds)]; + return this; + } + + toGeoJSON() { + const label = getFeatureType(this.kind).label; + const style = resolveFeatureStyle(this.kind, this.details); + + if (this.geometryType === "Point") { + const [first] = this.coordinates; + return { + type: "Feature", + properties: { + id: this.id, + kind: this.kind, + label, + name: this.name, + parentId: this.parentId, + groupIds: this.groupIds, + details: this.details, + style, + }, + geometry: { + type: "Point", + coordinates: [first.lon, first.lat], + }, + }; + } + + const coordinates = this.coordinates.map((coordinate) => [coordinate.lon, coordinate.lat]); + const geometry = + this.geometryType === "LineString" + ? { type: "LineString", coordinates } + : { type: "Polygon", coordinates: [closeRing(coordinates)] }; + + return { + type: "Feature", + properties: { + id: this.id, + kind: this.kind, + label, + name: this.name, + parentId: this.parentId, + groupIds: this.groupIds, + details: this.details, + style, + }, + geometry, + }; + } +} + +function buildFeatureId(kind, name) { + return `${kind}:${slugify(name)}`; +} + +function slugify(value) { + return String(value) + .toLowerCase() + .replaceAll(/[^a-z0-9]+/g, "-") + .replaceAll(/^-+|-+$/g, "") + .replaceAll(/-{2,}/g, "-"); +} + +function resolveFeatureReference(featureOrId) { + if (typeof featureOrId === "string") { + return featureOrId; + } + + if (featureOrId && typeof featureOrId.id === "string") { + return featureOrId.id; + } + + throw new Error("Expected a feature or feature id"); +} + +function resolveGroupReference(groupOrId) { + if (typeof groupOrId === "string") { + return groupOrId; + } + + if (groupOrId && typeof groupOrId.id === "string") { + return groupOrId.id; + } + + throw new Error("Expected a group or group id"); +} + +export function defineGroup(id, label, options = {}) { + return { + id, + label, + parentGroupId: options.parentGroupId ?? null, + }; +} + +class PolygonFeature extends YardFeature { + constructor(kind, name, details) { + super(kind, "Polygon", name, details); + } +} + +class LineFeature extends YardFeature { + constructor(kind, name, details) { + super(kind, "LineString", name, details); + } +} + +class PointFeature extends YardFeature { + constructor(kind, name, details) { + super(kind, "Point", name, details); + } +} + +function closeRing(coordinates) { + if (coordinates.length === 0) { + return coordinates; + } + + const [firstLon, firstLat] = coordinates[0]; + const [lastLon, lastLat] = coordinates[coordinates.length - 1]; + if (firstLon === lastLon && firstLat === lastLat) { + return coordinates; + } + + return [...coordinates, coordinates[0]]; +} + +function isPoint(value) { + return Boolean(value) && typeof value.lat === "number" && typeof value.lon === "number"; +} + +function isMove(value) { + return ( + Boolean(value) && + typeof value.northMeters === "number" && + typeof value.eastMeters === "number" + ); +} + +function isOffsetAnchor(value) { + return Boolean(value) && value.kind === "offset-anchor" && isPoint(value.origin); +} + +function resolveCoordinate(values) { + if (values.length === 1 && isPoint(values[0])) { + return values[0]; + } + + const [first, ...rest] = values; + if (isOffsetAnchor(first) && rest.every(isMove)) { + return offset(first.origin, ...rest); + } + + throw new Error("add(...) expects a point, or offset(origin) followed by directional moves"); +} + +export class LotBoundary extends PolygonFeature { + constructor(name = "Lot Boundary", details = {}) { + super("lotBoundary", name, details); + } +} + +export class Fence extends LineFeature { + constructor(name = "Fence", details = {}) { + super("fence", name, details); + } +} + +export class House extends PolygonFeature { + constructor(name = "House", details = {}) { + super("house", name, details); + } +} + +export class Garage extends PolygonFeature { + constructor(name = "Garage", details = {}) { + super("garage", name, details); + } +} + +export class Porch extends PolygonFeature { + constructor(name = "Porch", details = {}) { + super("porch", name, details); + } +} + +export class Deck extends PolygonFeature { + constructor(name = "Deck", details = {}) { + super("deck", name, details); + } +} + +export class Walkway extends PolygonFeature { + constructor(name = "Walkway", details = {}) { + super("walkway", name, details); + } +} + +export class Driveway extends PolygonFeature { + constructor(name = "Driveway", details = {}) { + super("driveway", name, details); + } +} + +export class FlowerBed extends PolygonFeature { + constructor(name = "Flower Bed", details = {}) { + super("flowerBed", name, details); + } +} + +export class Tree extends PointFeature { + constructor(name = "Tree", details = {}) { + super("tree", name, details); + } +} + +export class Sprinkler extends PointFeature { + constructor(name = "Sprinkler", details = {}) { + super("sprinkler", name, details); + } +} + +export function collectYardData(features, groups = []) { + const collection = collectGeoJSON(features); + collection.groups = groups.map((group) => ({ + id: group.id, + label: group.label, + parentGroupId: group.parentGroupId ?? null, + })); + return collection; +} + +export { collectGeoJSON }; \ No newline at end of file diff --git a/src/main.js b/src/main.js new file mode 100644 index 0000000..d558fe7 --- /dev/null +++ b/src/main.js @@ -0,0 +1,139 @@ +import { yardGeoJSON } from "./data/yard.js"; + +const TILE_MAX_NATIVE_ZOOM = 19; +const MAP_MAX_ZOOM = 24; + +const map = L.map("map", { + zoomControl: false, + maxZoom: MAP_MAX_ZOOM, +}); + +L.control + .zoom({ + position: "bottomright", + }) + .addTo(map); + +L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", { + maxNativeZoom: TILE_MAX_NATIVE_ZOOM, + maxZoom: MAP_MAX_ZOOM, + attribution: '© OpenStreetMap', +}).addTo(map); + +const geoJsonLayer = L.geoJSON(yardGeoJSON, { + pointToLayer(feature, latlng) { + const style = feature.properties.style; + return L.circleMarker(latlng, { + radius: style.radius ?? 6, + color: style.color ?? "#333333", + fillColor: style.fillColor ?? style.color ?? "#333333", + fillOpacity: style.fillOpacity ?? 0.8, + weight: style.weight ?? 2, + }); + }, + style(feature) { + return feature.properties.style; + }, + onEachFeature(feature, layer) { + layer.bindPopup(buildPopupContent(feature.properties)); + }, +}).addTo(map); + +map.fitBounds(geoJsonLayer.getBounds().pad(0.2), { maxZoom: MAP_MAX_ZOOM }); + +const legend = document.getElementById("legend"); +const legendEntries = summarizeTypes(yardGeoJSON.features); + +legend.replaceChildren( + ...legendEntries.map((entry) => { + const row = document.createElement("div"); + row.className = "legend-item"; + + const swatch = document.createElement("span"); + swatch.className = "legend-swatch"; + swatch.style.background = entry.color; + + const label = document.createElement("span"); + label.textContent = `${entry.label} (${entry.count})`; + + row.append(swatch, label); + return row; + }), +); + +function summarizeTypes(features) { + const summary = new Map(); + + for (const feature of features) { + const key = feature.properties.kind; + const existing = summary.get(key) ?? { + label: feature.properties.label, + count: 0, + color: feature.properties.style.color ?? "#333333", + }; + + existing.count += 1; + summary.set(key, existing); + } + + return [...summary.values()]; +} + +function buildPopupContent(properties) { + const { label, name, details, parentId, groupIds } = properties; + const entries = Object.entries(details ?? {}).filter(([, value]) => value !== null && value !== undefined); + const relationshipEntries = []; + + if (parentId) { + relationshipEntries.push(["parentFeature", parentId]); + } + + if (groupIds?.length) { + relationshipEntries.push(["groups", groupIds.join(", ")]); + } + + const title = `${escapeHtml(name)}`; + const subtitle = name === label ? "" : `
${escapeHtml(label)}
`; + const allEntries = [...entries, ...relationshipEntries]; + if (allEntries.length === 0) { + return `${title}${subtitle}`; + } + + const detailRows = allEntries + .map(([key, value]) => `
${escapeHtml(formatDetailLabel(key))}: ${escapeHtml(formatDetailValue(key, value))}
`) + .join(""); + + return `${title}${subtitle}
${detailRows}
`; +} + +function formatDetailLabel(key) { + const labels = { + canopyDiameterMeters: "Canopy diameter", + heightMeters: "Height", + plantedDate: "Planted", + species: "Species", + fenceType: "Fence type", + note: "Note", + parentFeature: "Parent", + groups: "Groups", + }; + + return labels[key] ?? key; +} + +function formatDetailValue(key, value) { + if (key === "canopyDiameterMeters" || key === "heightMeters") { + return `${value} m`; + } + + return String(value); +} + +function escapeHtml(value) { + return String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} \ No newline at end of file diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..f55f3ac --- /dev/null +++ b/styles.css @@ -0,0 +1,136 @@ +:root { + color-scheme: light; + --bg: #f3efe2; + --panel: rgba(253, 250, 240, 0.88); + --panel-border: rgba(78, 62, 36, 0.15); + --ink: #2e2619; + --muted: #64563f; + --accent: #466b45; + --accent-soft: #cedbbf; + --shadow: 0 24px 80px rgba(60, 44, 22, 0.18); +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + min-height: 100%; + font-family: "Iowan Old Style", "Palatino Linotype", "URW Palladio L", serif; + color: var(--ink); + background: + radial-gradient(circle at top left, rgba(255, 255, 255, 0.72), transparent 30%), + linear-gradient(135deg, #e7e0c7 0%, #f7f3e8 50%, #e4ead9 100%); +} + +body { + min-height: 100vh; +} + +.app-shell { + display: grid; + grid-template-columns: minmax(280px, 360px) minmax(0, 1fr); + min-height: 100vh; + gap: 1rem; + padding: 1rem; +} + +.panel { + padding: 1.5rem; + border: 1px solid var(--panel-border); + border-radius: 24px; + background: var(--panel); + box-shadow: var(--shadow); + backdrop-filter: blur(10px); +} + +.eyebrow { + margin: 0; + text-transform: uppercase; + letter-spacing: 0.18em; + font-size: 0.72rem; + color: var(--accent); +} + +h1 { + margin: 0.4rem 0 1rem; + font-size: clamp(2.2rem, 4vw, 3.6rem); + line-height: 0.95; +} + +.intro, +.notes { + color: var(--muted); + line-height: 1.5; +} + +.legend { + display: grid; + gap: 0.65rem; + margin: 1.5rem 0; +} + +.legend-item { + display: flex; + align-items: center; + gap: 0.75rem; + font-size: 0.95rem; +} + +.legend-swatch { + width: 0.9rem; + height: 0.9rem; + border-radius: 999px; + flex: none; + border: 1px solid rgba(0, 0, 0, 0.12); +} + +.notes h2 { + margin-bottom: 0.6rem; + color: var(--ink); + font-size: 1rem; +} + +pre { + margin: 0; + padding: 0.9rem; + overflow-x: auto; + border-radius: 16px; + background: rgba(70, 107, 69, 0.08); +} + +code { + font-family: "JetBrains Mono", "Fira Code", monospace; + font-size: 0.86rem; +} + +main { + min-width: 0; +} + +#map { + min-height: calc(100vh - 2rem); + border-radius: 28px; + overflow: hidden; + box-shadow: var(--shadow); +} + +.leaflet-popup-content-wrapper { + border-radius: 14px; +} + +.leaflet-popup-content { + font-family: "Iowan Old Style", "Palatino Linotype", "URW Palladio L", serif; +} + +@media (max-width: 900px) { + .app-shell { + grid-template-columns: 1fr; + } + + #map { + min-height: 65vh; + } +} \ No newline at end of file From c5725a02595566f53b41a32e6bed6c7f28f745c7 Mon Sep 17 00:00:00 2001 From: Aaron Axvig Date: Fri, 26 Jun 2026 15:48:12 -0500 Subject: [PATCH 02/23] Many improvements --- AGENTS.md | 10 +- README.md | 5 +- index.html | 14 +- src/assets/icons/daylily.svg | 45 ++ src/data/anchors.js | 5 +- src/data/boundaries.js | 13 +- src/data/grid.js | 57 ++ src/data/plants.js | 45 +- src/data/settings.js | 1 - src/data/yard.js | 5 +- src/error-overlay.js | 82 +++ src/lib/feature-types.js | 194 ++++++- src/lib/yard-features.js | 124 ++++- src/main.js | 163 +++++- styles.css | 59 +- vendor/leaflet/images/layers-2x.png | Bin 0 -> 1259 bytes vendor/leaflet/images/layers.png | Bin 0 -> 696 bytes vendor/leaflet/images/marker-icon-2x.png | Bin 0 -> 2464 bytes vendor/leaflet/images/marker-icon.png | Bin 0 -> 1466 bytes vendor/leaflet/images/marker-shadow.png | Bin 0 -> 618 bytes vendor/leaflet/leaflet.css | 661 +++++++++++++++++++++++ vendor/leaflet/leaflet.js | 6 + 22 files changed, 1392 insertions(+), 97 deletions(-) create mode 100644 src/assets/icons/daylily.svg create mode 100644 src/data/grid.js delete mode 100644 src/data/settings.js create mode 100644 src/error-overlay.js create mode 100644 vendor/leaflet/images/layers-2x.png create mode 100644 vendor/leaflet/images/layers.png create mode 100644 vendor/leaflet/images/marker-icon-2x.png create mode 100644 vendor/leaflet/images/marker-icon.png create mode 100644 vendor/leaflet/images/marker-shadow.png create mode 100644 vendor/leaflet/leaflet.css create mode 100644 vendor/leaflet/leaflet.js diff --git a/AGENTS.md b/AGENTS.md index 047b4ed..d358fb5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,14 +16,17 @@ Static client-side yard mapping app. Core files: - `src/lib/geometry.js`: absolute points, local offsets, feet conversion, per-anchor rotation support. -- `src/lib/feature-types.js`: feature-kind style defaults. -- `src/lib/yard-features.js`: typed feature classes, stable ids, groups, parent-child relationships, GeoJSON export. +- `src/lib/feature-types.js`: feature-kind labels, geometry constraints, detail field schemas, and style defaults. +- `src/lib/yard-features.js`: typed feature classes, stable ids, groups, parent-child relationships, schema metadata, GeoJSON export. - `src/data/*.js`: yard content split by domain. - `src/main.js`: Leaflet map, legend, popup rendering. Top-level exported data shape from `src/data/yard.js`: - `type: "FeatureCollection"` +- `schemaVersion: 1` +- `lifecycleFields: { startDate: "date", endDate: "date" }` +- `featureTypes: [...]` - `features: [...]` - `groups: [...]` @@ -33,10 +36,11 @@ Each feature currently exports: - `properties.kind` - `properties.label` - `properties.name` +- `properties.startDate` +- `properties.endDate` - `properties.parentId` - `properties.groupIds` - `properties.details` -- `properties.style` ## Important decisions already made diff --git a/README.md b/README.md index 5a78759..9c093b0 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ The project currently has: - A static Leaflet map with OSM raster tiles. - Client-side JavaScript-authored yard geometry converted to GeoJSON at load time. - Typed feature classes with centralized styling. +- Schema-versioned yard data with a top-level feature type catalog. - Per-anchor local coordinate rotation for offset-based authoring. - Feature relationship metadata (`id`, `parentId`, `groupIds`) and a top-level group catalog for future visibility controls. - Example yard data mostly copied from `buildings.gpkg` and then preserved as JS modules. @@ -33,13 +34,13 @@ The example yard is now split by concern: - `src/data/plants.js` - `src/data/irrigation.js` -The typed feature registry lives in `src/lib/feature-types.js`, feature classes live in `src/lib/yard-features.js`, and shared coordinate helpers live in `src/lib/geometry.js`. +The typed feature registry lives in `src/lib/feature-types.js`, feature classes live in `src/lib/yard-features.js`, and shared coordinate helpers live in `src/lib/geometry.js`. Feature type definitions include the label, required geometry type, known detail fields, and default Leaflet style. Local `north`, `south`, `east`, and `west` offsets are rotated per anchor. In this example, `topLeftLotCorner` in `src/data/anchors.js` carries `assumedNorthClockwiseDegrees` from `src/data/settings.js`. A negative value means that anchor's local north is counterclockwise from true north, so local east drifts a bit toward true north. Most example geometry was copied from `buildings.gpkg` and preserved as JS-authored coordinates. Tree records also carry custom attributes like canopy diameter, height, and planted date for popup rendering. -Feature relationships are exported with each GeoJSON feature as stable `id`, `parentId`, and `groupIds` properties, and the collection also exposes a top-level `groups` array. The current sample marks the porch and deck as part of the house and groups the Medora junipers under `Juniper Trees`. +Feature relationships are exported with each GeoJSON feature as stable `id`, `parentId`, and `groupIds` properties. Each feature also exports universal `startDate` and `endDate` lifecycle fields for future time-slider filtering. The collection exposes `schemaVersion`, `lifecycleFields`, `featureTypes`, and `groups` at the top level. Style is resolved from the feature type registry at render time rather than stored on each exported feature. The current sample marks the porch and deck as part of the house and groups the Medora junipers under `Juniper Trees`. ## Project structure diff --git a/index.html b/index.html index cd2cb15..9d61ed1 100644 --- a/index.html +++ b/index.html @@ -4,12 +4,7 @@ Yard Map - + @@ -40,11 +35,8 @@ new Sprinkler("Front bed sprinkler") - + + diff --git a/src/assets/icons/daylily.svg b/src/assets/icons/daylily.svg new file mode 100644 index 0000000..6384ac2 --- /dev/null +++ b/src/assets/icons/daylily.svg @@ -0,0 +1,45 @@ + + Daylily + Abstract daylily clump with spindly fronds and flower stalks. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/data/anchors.js b/src/data/anchors.js index f7a197b..75f7735 100644 --- a/src/data/anchors.js +++ b/src/data/anchors.js @@ -1,7 +1,6 @@ import { anchor } from "../lib/geometry.js"; -import { lotAssumedNorthClockwiseDegrees } from "./settings.js"; -export const topLeftLotCorner = anchor(-100.89895832874616, 46.82679973173392, { - assumedNorthClockwiseDegrees: lotAssumedNorthClockwiseDegrees, +export const topLeftLotCorner = anchor(-100.898958, 46.826799, { + assumedNorthClockwiseDegrees: -11.25, }); export const lotCorner = topLeftLotCorner; \ No newline at end of file diff --git a/src/data/boundaries.js b/src/data/boundaries.js index ae61595..6208fdc 100644 --- a/src/data/boundaries.js +++ b/src/data/boundaries.js @@ -1,11 +1,12 @@ +import * as geo from "../lib/geometry.js"; import { Fence, LotBoundary } from "../lib/yard-features.js"; +import { lotCorner } from "./anchors.js"; -const lotBoundary = new LotBoundary().trace([ - [-100.89895832874616, 46.82679973173392], - [-100.89839320971627, 46.8268766687031], - [-100.89835104203355, 46.82673956300183], - [-100.89891868391628, 46.82666533836659], -]); +const lotBoundary = new LotBoundary() + .add(geo.offset(lotCorner)) + .add(geo.offset(lotCorner), geo.east(geo.ft(144))) + .add(geo.offset(lotCorner), geo.east(geo.ft(144)), geo.south(geo.ft(51))) + .add(geo.offset(lotCorner), geo.south(geo.ft(51))); const picketFence = new Fence("Short picket fence", { fenceType: "Short picket", diff --git a/src/data/grid.js b/src/data/grid.js new file mode 100644 index 0000000..3e199dd --- /dev/null +++ b/src/data/grid.js @@ -0,0 +1,57 @@ +import * as geo from "../lib/geometry.js"; +import { defineGroup, YardGridLine } from "../lib/yard-features.js"; +import { lotCorner } from "./anchors.js"; + +const GRID_SPACING_FEET = 10; +const MIN_EAST_FEET = -20; +const MAX_EAST_FEET = 180; +const MIN_NORTH_FEET = -140; +const MAX_NORTH_FEET = 20; + +export const yardGridGroup = defineGroup("group:yard-grid", "Yard Grid"); + +function localPoint(eastFeet, northFeet) { + return geo.offset( + lotCorner, + geo.east(geo.ft(eastFeet)), + northFeet >= 0 ? geo.north(geo.ft(northFeet)) : geo.south(geo.ft(Math.abs(northFeet))), + ); +} + +function makeNorthLine(eastFeet) { + return new YardGridLine(`Grid east ${eastFeet} ft`, { + axis: "north", + offsetFeet: eastFeet, + spacingFeet: GRID_SPACING_FEET, + }) + .inGroup(yardGridGroup) + .add(localPoint(eastFeet, MIN_NORTH_FEET)) + .add(localPoint(eastFeet, MAX_NORTH_FEET)); +} + +function makeEastLine(northFeet) { + return new YardGridLine(`Grid north ${northFeet} ft`, { + axis: "east", + offsetFeet: northFeet, + spacingFeet: GRID_SPACING_FEET, + }) + .inGroup(yardGridGroup) + .add(localPoint(MIN_EAST_FEET, northFeet)) + .add(localPoint(MAX_EAST_FEET, northFeet)); +} + +function rangeByFeet(minFeet, maxFeet, spacingFeet) { + const values = []; + + for (let value = minFeet; value <= maxFeet; value += spacingFeet) { + values.push(value); + } + + return values; +} + +const northLines = rangeByFeet(MIN_EAST_FEET, MAX_EAST_FEET, GRID_SPACING_FEET).map(makeNorthLine); +const eastLines = rangeByFeet(MIN_NORTH_FEET, MAX_NORTH_FEET, GRID_SPACING_FEET).map(makeEastLine); + +export const gridFeatures = [...northLines, ...eastLines]; +export const gridGroups = [yardGridGroup]; diff --git a/src/data/plants.js b/src/data/plants.js index d7fa3df..c70a6c9 100644 --- a/src/data/plants.js +++ b/src/data/plants.js @@ -1,6 +1,6 @@ -import { defineGroup, FlowerBed, Tree } from "../lib/yard-features.js"; -import { east, ft, lngLat, offset } from "../lib/geometry.js"; -import { topLeftLotCorner } from "./anchors.js"; +import { Daylily, defineGroup, FlowerBed, Tree } from "../lib/yard-features.js"; +import * as geo from "../lib/geometry.js"; +import { lotCorner } from "./anchors.js"; export const juniperTreesGroup = defineGroup("group:juniper-trees", "Juniper Trees"); @@ -47,60 +47,75 @@ const flowerBeds = [ ]), ]; +const daylilies = [ + new Daylily("You Had Me At Merlot", { + daylilyDatabaseId: "194128", + daylilyDatabaseUrl: "https://daylilydatabase.org/detail.php?id=194128", + }).add(geo.offset(lotCorner), geo.east(geo.ft(28)), geo.south(geo.ft(45))), + new Daylily("Mapping North Dakota", { + daylilyDatabaseId: "158640", + daylilyDatabaseUrl: "https://daylilydatabase.org/detail.php?id=158640", + }).add(geo.offset(lotCorner), geo.east(geo.ft(28)), geo.south(geo.ft(47))), + new Daylily("Daylily", { + daylilyDatabaseId: "", + daylilyDatabaseUrl: "", + }).add(geo.offset(lotCorner), geo.east(geo.ft(31)), geo.south(geo.ft(47))), +]; + const trees = [ new Tree("Japanese Empress Elm", { species: "Japanese Empress Elm", canopyDiameterMeters: 6, heightMeters: 7, - }).add(lngLat(-100.89889018162828, 46.82675624026659)), + }).add(geo.offset(lotCorner), geo.east(geo.ft(9)), geo.south(geo.ft(16))), new Tree("Boulevard Linden", { species: "Boulevard Linden", canopyDiameterMeters: 3, heightMeters: 6, plantedDate: "2021-06-08", - }).add(lngLat(-100.8990003556445, 46.826776648309774)), + }).add(geo.lngLat(-100.8990003556445, 46.826776648309774)), new Tree("Boulevard Linden 2", { species: "Boulevard Linden", canopyDiameterMeters: 3, heightMeters: 6, - }).add(lngLat(-100.89898269567485, 46.826709328342595)), + }).add(geo.lngLat(-100.89898269567485, 46.826709328342595)), new Tree("Evergreen", { species: "Evergreen", canopyDiameterMeters: 8, heightMeters: 15, - }).add(lngLat(-100.89888268258135, 46.826680107011924)), + }).add(geo.lngLat(-100.89888268258135, 46.826680107011924)), new Tree("Medora Juniper 1", { species: "Medora Juniper", canopyDiameterMeters: 1.5, heightMeters: 2.5, - }).inGroup(juniperTreesGroup).add(lngLat(-100.89880933964611, 46.826705259550664)), + }).inGroup(juniperTreesGroup).add(geo.lngLat(-100.89880933964611, 46.826705259550664)), new Tree("Medora Juniper 2", { species: "Medora Juniper", canopyDiameterMeters: 1.5, heightMeters: 2.5, - }).inGroup(juniperTreesGroup).add(lngLat(-100.89877004677517, 46.82670396593881)), + }).inGroup(juniperTreesGroup).add(geo.lngLat(-100.89877004677517, 46.82670396593881)), new Tree("Medora Juniper 3", { species: "Medora Juniper", canopyDiameterMeters: 1.5, heightMeters: 2.5, - }).inGroup(juniperTreesGroup).add(lngLat(-100.898799106227, 46.82669830398086)), + }).inGroup(juniperTreesGroup).add(geo.lngLat(-100.898799106227, 46.82669830398086)), new Tree("Medora Juniper 4", { species: "Medora Juniper", canopyDiameterMeters: 1.5, heightMeters: 2.5, - }).inGroup(juniperTreesGroup).add(lngLat(-100.89878871410515, 46.82669251034885)), + }).inGroup(juniperTreesGroup).add(geo.lngLat(-100.89878871410515, 46.82669251034885)), new Tree("Medora Juniper 5", { species: "Medora Juniper", canopyDiameterMeters: 1.5, heightMeters: 2.5, - }).inGroup(juniperTreesGroup).add(lngLat(-100.8987812086838, 46.82671120797719)), + }).inGroup(juniperTreesGroup).add(geo.lngLat(-100.8987812086838, 46.82671120797719)), new Tree("Offset Test Tree", { species: "Test tree", canopyDiameterMeters: 4, heightMeters: 5, note: "70 feet east of top-left lot corner using assumedNorth", - }).add(offset(topLeftLotCorner), east(ft(70))), + }).add(geo.offset(lotCorner), geo.east(geo.ft(70))), ]; -export const plantFeatures = [...flowerBeds, ...trees]; -export const plantGroups = [juniperTreesGroup]; \ No newline at end of file +export const plantFeatures = [...flowerBeds, ...daylilies, ...trees]; +export const plantGroups = [juniperTreesGroup]; diff --git a/src/data/settings.js b/src/data/settings.js deleted file mode 100644 index 0700634..0000000 --- a/src/data/settings.js +++ /dev/null @@ -1 +0,0 @@ -export const lotAssumedNorthClockwiseDegrees = -11.25369719051117; \ No newline at end of file diff --git a/src/data/yard.js b/src/data/yard.js index 88f411b..9f4dda3 100644 --- a/src/data/yard.js +++ b/src/data/yard.js @@ -1,6 +1,7 @@ import { collectYardData } from "../lib/yard-features.js"; import { boundaryFeatures } from "./boundaries.js"; import { buildingFeatures, buildingGroups } from "./buildings.js"; +import { gridFeatures, gridGroups } from "./grid.js"; import { irrigationFeatures } from "./irrigation.js"; import { pavementFeatures } from "./pavement.js"; import { plantFeatures, plantGroups } from "./plants.js"; @@ -8,6 +9,7 @@ import { plantFeatures, plantGroups } from "./plants.js"; export const yardGeoJSON = collectYardData( [ ...boundaryFeatures, + ...gridFeatures, ...buildingFeatures, ...pavementFeatures, ...plantFeatures, @@ -15,6 +17,7 @@ export const yardGeoJSON = collectYardData( ], [ ...buildingGroups, + ...gridGroups, ...plantGroups, ], -); \ No newline at end of file +); diff --git a/src/error-overlay.js b/src/error-overlay.js new file mode 100644 index 0000000..cb73f16 --- /dev/null +++ b/src/error-overlay.js @@ -0,0 +1,82 @@ +const overlay = document.createElement("section"); +overlay.className = "error-overlay"; +overlay.hidden = true; +overlay.innerHTML = ` +
+ JavaScript error +
+ + +
+
+

+`;
+
+const messageNode = overlay.querySelector("pre");
+const copyButton = overlay.querySelector("[data-action='copy']");
+const dismissButton = overlay.querySelector("[data-action='dismiss']");
+
+copyButton.addEventListener("click", async () => {
+  await copyText(messageNode.textContent);
+  showCopyStatus();
+});
+
+dismissButton.addEventListener("click", () => {
+  overlay.hidden = true;
+});
+
+document.addEventListener("DOMContentLoaded", () => {
+  document.body.append(overlay);
+});
+
+window.addEventListener("error", (event) => {
+  showError(formatErrorEvent(event));
+});
+
+window.addEventListener("unhandledrejection", (event) => {
+  showError(formatReason(event.reason));
+});
+
+function showError(message) {
+  messageNode.textContent = message;
+  copyButton.textContent = "Copy";
+  overlay.hidden = false;
+}
+
+async function copyText(value) {
+  if (navigator.clipboard?.writeText) {
+    await navigator.clipboard.writeText(value);
+    return;
+  }
+
+  const textArea = document.createElement("textarea");
+  textArea.value = value;
+  textArea.setAttribute("readonly", "");
+  textArea.className = "error-overlay-copy-buffer";
+  document.body.append(textArea);
+  textArea.select();
+  document.execCommand("copy");
+  textArea.remove();
+}
+
+function showCopyStatus() {
+  copyButton.textContent = "Copied";
+
+  window.setTimeout(() => {
+    copyButton.textContent = "Copy";
+  }, 1400);
+}
+
+function formatErrorEvent(event) {
+  const location = [event.filename, event.lineno, event.colno].filter(Boolean).join(":");
+  const detail = formatReason(event.error ?? event.message);
+  return location ? `${detail}\n\n${location}` : detail;
+}
+
+function formatReason(reason) {
+  if (reason instanceof Error) {
+    return reason.stack ?? `${reason.name}: ${reason.message}`;
+  }
+
+  return String(reason);
+}
diff --git a/src/lib/feature-types.js b/src/lib/feature-types.js
index 1bbb191..6a1b4f9 100644
--- a/src/lib/feature-types.js
+++ b/src/lib/feature-types.js
@@ -1,15 +1,8 @@
-const treeRadiusRange = {
-  min: 5,
-  max: 14,
-};
-
-function clamp(value, min, max) {
-  return Math.min(max, Math.max(min, value));
-}
-
 export const featureTypes = {
   lotBoundary: {
     label: "Lot Boundary",
+    geometryType: "Polygon",
+    detailFields: {},
     style: {
       color: "#7b684d",
       weight: 2,
@@ -20,14 +13,35 @@ export const featureTypes = {
   },
   fence: {
     label: "Fence",
+    geometryType: "LineString",
+    detailFields: {
+      fenceType: "string",
+    },
     style: {
       color: "#705033",
       weight: 4,
       opacity: 0.95,
     },
   },
+  yardGrid: {
+    label: "Yard Grid",
+    geometryType: "LineString",
+    detailFields: {
+      axis: "string",
+      offsetFeet: "number",
+      spacingFeet: "number",
+    },
+    style: (details) => ({
+      color: details.offsetFeet === 0 ? "#1f4f63" : "#2f7f98",
+      weight: details.offsetFeet === 0 ? 3 : 2,
+      opacity: details.offsetFeet === 0 ? 0.75 : 0.55,
+      dashArray: details.offsetFeet === 0 ? undefined : "6 5",
+    }),
+  },
   house: {
     label: "House",
+    geometryType: "Polygon",
+    detailFields: {},
     style: {
       color: "#8d5a35",
       weight: 2,
@@ -37,6 +51,8 @@ export const featureTypes = {
   },
   garage: {
     label: "Garage",
+    geometryType: "Polygon",
+    detailFields: {},
     style: {
       color: "#60636b",
       weight: 2,
@@ -46,6 +62,8 @@ export const featureTypes = {
   },
   porch: {
     label: "Porch",
+    geometryType: "Polygon",
+    detailFields: {},
     style: {
       color: "#aa7148",
       weight: 2,
@@ -55,6 +73,8 @@ export const featureTypes = {
   },
   deck: {
     label: "Deck",
+    geometryType: "Polygon",
+    detailFields: {},
     style: {
       color: "#7f5a3c",
       weight: 2,
@@ -64,6 +84,8 @@ export const featureTypes = {
   },
   walkway: {
     label: "Walkway",
+    geometryType: "Polygon",
+    detailFields: {},
     style: {
       color: "#b8b3a6",
       weight: 2,
@@ -73,6 +95,8 @@ export const featureTypes = {
   },
   driveway: {
     label: "Driveway",
+    geometryType: "Polygon",
+    detailFields: {},
     style: {
       color: "#9c9890",
       weight: 2,
@@ -82,6 +106,12 @@ export const featureTypes = {
   },
   flowerBed: {
     label: "Flower Bed",
+    geometryType: "Polygon",
+    detailFields: {
+      soil: "string",
+      mulch: "string",
+      sunExposure: "string",
+    },
     style: {
       color: "#7f7f31",
       weight: 2,
@@ -89,18 +119,130 @@ export const featureTypes = {
       fillOpacity: 0.38,
     },
   },
+  plant: {
+    label: "Plant",
+    geometryType: "Point",
+    detailFields: {
+      commonName: "string",
+      genus: "string",
+      species: "string",
+      cultivar: "string",
+      plantedDate: "date",
+      note: "string",
+    },
+    style: {
+      color: "#3d7140",
+      weight: 2,
+      radius: 5,
+      fillColor: "#78a658",
+      fillOpacity: 0.8,
+    },
+  },
+  flower: {
+    label: "Flower",
+    parentKind: "plant",
+    geometryType: "Point",
+    detailFields: {
+      commonName: "string",
+      genus: "string",
+      species: "string",
+      cultivar: "string",
+      bloomColor: "string",
+      bloomSeason: "string",
+      plantedDate: "date",
+      note: "string",
+    },
+    style: {
+      color: "#9c3f74",
+      weight: 2,
+      radius: 5,
+      fillColor: "#d979aa",
+      fillOpacity: 0.85,
+    },
+  },
+  daylily: {
+    label: "Daylily",
+    parentKind: "flower",
+    geometryType: "Point",
+    detailFields: {
+      commonName: "string",
+      genus: "string",
+      cultivar: "string",
+      bloomColor: "string",
+      bloomSeason: "string",
+      daylilyDatabaseId: "string",
+      daylilyDatabaseUrl: "url",
+      plantedDate: "date",
+      note: "string",
+    },
+    externalLinks: [
+      {
+        label: "Daylily database",
+        url: (details) => details.daylilyDatabaseUrl ?? null,
+      },
+    ],
+    style: {
+      color: "#9f5e1d",
+      weight: 2,
+      radius: 5,
+      fillColor: "#f0b44c",
+      fillOpacity: 0.88,
+      iconUrl: "./src/assets/icons/daylily.svg",
+      iconDiameterMeters: 2.5 / 3.280839895,
+    },
+  },
+  shrub: {
+    label: "Shrub",
+    parentKind: "plant",
+    geometryType: "Point",
+    detailFields: {
+      commonName: "string",
+      genus: "string",
+      species: "string",
+      cultivar: "string",
+      canopyDiameterMeters: "number",
+      heightMeters: "number",
+      plantedDate: "date",
+      note: "string",
+    },
+    style: (details) => ({
+      color: "#426a3d",
+      weight: 1,
+      radiusMeters: (details.canopyDiameterMeters ?? 1) / 2,
+      fillColor: "#7e9d58",
+      fillOpacity: 0.62,
+    }),
+  },
   tree: {
     label: "Tree",
+    parentKind: "plant",
+    geometryType: "Point",
+    detailFields: {
+      commonName: "string",
+      genus: "string",
+      species: "string",
+      cultivar: "string",
+      canopyDiameterMeters: "number",
+      heightMeters: "number",
+      plantedDate: "date",
+      note: "string",
+    },
     style: (details) => ({
       color: "#2f6b3d",
-      weight: 2,
-      radius: clamp(4 + (details.canopyDiameterMeters ?? 2), treeRadiusRange.min, treeRadiusRange.max),
+      weight: 1,
+      radiusMeters: (details.canopyDiameterMeters ?? 2) / 2,
       fillColor: "#5c9e64",
-      fillOpacity: 0.85,
+      fillOpacity: 0.55,
     }),
   },
   sprinkler: {
     label: "Sprinkler",
+    geometryType: "Point",
+    detailFields: {
+      zone: "string",
+      headType: "string",
+      flowRateGallonsPerMinute: "number",
+    },
     style: {
       color: "#2d7dd2",
       radius: 5,
@@ -119,7 +261,31 @@ export function getFeatureType(kind) {
   return definition;
 }
 
+export function listFeatureTypeSchemas() {
+  return Object.entries(featureTypes).map(([kind, definition]) => ({
+    kind,
+    label: definition.label,
+    parentKind: definition.parentKind ?? null,
+    geometryType: definition.geometryType,
+    detailFields: { ...definition.detailFields },
+    externalLinks: (definition.externalLinks ?? []).map((link) => ({
+      label: link.label,
+    })),
+  }));
+}
+
 export function resolveFeatureStyle(kind, details = {}) {
   const { style } = getFeatureType(kind);
-  return typeof style === "function" ? style(details) : style;
-}
\ No newline at end of file
+  return typeof style === "function" ? style(details) : { ...style };
+}
+
+export function resolveFeatureLinks(kind, details = {}) {
+  const { externalLinks = [] } = getFeatureType(kind);
+
+  return externalLinks
+    .map((link) => ({
+      label: link.label,
+      url: link.url(details),
+    }))
+    .filter((link) => link.url);
+}
diff --git a/src/lib/yard-features.js b/src/lib/yard-features.js
index 85ec4a7..b6d2858 100644
--- a/src/lib/yard-features.js
+++ b/src/lib/yard-features.js
@@ -1,13 +1,25 @@
 import { collectGeoJSON, offset, pointsFromLngLat } from "./geometry.js";
-import { getFeatureType, resolveFeatureStyle } from "./feature-types.js";
+import { getFeatureType, listFeatureTypeSchemas } from "./feature-types.js";
+
+export const YARD_SCHEMA_VERSION = 1;
 
 class YardFeature {
   constructor(kind, geometryType, name, details = {}) {
+    const featureType = getFeatureType(kind);
+    if (featureType.geometryType !== geometryType) {
+      throw new Error(
+        `${kind} features must use ${featureType.geometryType} geometry, got ${geometryType}`,
+      );
+    }
+
     this.kind = kind;
     this.geometryType = geometryType;
-    this.name = name ?? getFeatureType(kind).label;
+    this.name = name ?? featureType.label;
     this.id = buildFeatureId(kind, this.name);
-    this.details = { ...details };
+    const { startDate = null, endDate = null, ...restDetails } = details;
+    this.startDate = startDate;
+    this.endDate = endDate;
+    this.details = { ...restDetails };
     this.coordinates = [];
     this.parentId = null;
     this.groupIds = [];
@@ -36,6 +48,22 @@ class YardFeature {
     return this;
   }
 
+  activeFrom(startDate) {
+    this.startDate = startDate;
+    return this;
+  }
+
+  activeUntil(endDate) {
+    this.endDate = endDate;
+    return this;
+  }
+
+  withDateRange(startDate, endDate = null) {
+    this.startDate = startDate;
+    this.endDate = endDate;
+    return this;
+  }
+
   childOf(parent) {
     this.parentId = resolveFeatureReference(parent);
     return this;
@@ -49,22 +77,23 @@ class YardFeature {
 
   toGeoJSON() {
     const label = getFeatureType(this.kind).label;
-    const style = resolveFeatureStyle(this.kind, this.details);
+    const properties = {
+      id: this.id,
+      kind: this.kind,
+      label,
+      name: this.name,
+      startDate: this.startDate,
+      endDate: this.endDate,
+      parentId: this.parentId,
+      groupIds: this.groupIds,
+      details: this.details,
+    };
 
     if (this.geometryType === "Point") {
       const [first] = this.coordinates;
       return {
         type: "Feature",
-        properties: {
-          id: this.id,
-          kind: this.kind,
-          label,
-          name: this.name,
-          parentId: this.parentId,
-          groupIds: this.groupIds,
-          details: this.details,
-          style,
-        },
+        properties,
         geometry: {
           type: "Point",
           coordinates: [first.lon, first.lat],
@@ -80,16 +109,7 @@ class YardFeature {
 
     return {
       type: "Feature",
-      properties: {
-        id: this.id,
-        kind: this.kind,
-        label,
-        name: this.name,
-        parentId: this.parentId,
-        groupIds: this.groupIds,
-        details: this.details,
-        style,
-      },
+      properties,
       geometry,
     };
   }
@@ -194,6 +214,10 @@ function resolveCoordinate(values) {
 
   const [first, ...rest] = values;
   if (isOffsetAnchor(first) && rest.every(isMove)) {
+    if (rest.length === 0) {
+      return first.origin;
+    }
+
     return offset(first.origin, ...rest);
   }
 
@@ -212,6 +236,12 @@ export class Fence extends LineFeature {
   }
 }
 
+export class YardGridLine extends LineFeature {
+  constructor(name = "Yard Grid", details = {}) {
+    super("yardGrid", name, details);
+  }
+}
+
 export class House extends PolygonFeature {
   constructor(name = "House", details = {}) {
     super("house", name, details);
@@ -254,9 +284,43 @@ export class FlowerBed extends PolygonFeature {
   }
 }
 
-export class Tree extends PointFeature {
+export class Plant extends PointFeature {
+  constructor(name = "Plant", details = {}, kind = "plant") {
+    super(kind, name, details);
+  }
+}
+
+export class Flower extends Plant {
+  constructor(name = "Flower", details = {}, kind = "flower") {
+    super(name, details, kind);
+  }
+}
+
+export class Daylily extends Flower {
+  constructor(name = "Daylily", details = {}) {
+    super(name, {
+      commonName: "Daylily",
+      genus: "Hemerocallis",
+      ...details,
+    }, "daylily");
+  }
+}
+
+export class Shrub extends Plant {
+  constructor(name = "Shrub", details = {}) {
+    super(name, details, "shrub");
+  }
+}
+
+export class Bush extends Shrub {
+  constructor(name = "Bush", details = {}) {
+    super(name, details);
+  }
+}
+
+export class Tree extends Plant {
   constructor(name = "Tree", details = {}) {
-    super("tree", name, details);
+    super(name, details, "tree");
   }
 }
 
@@ -268,6 +332,12 @@ export class Sprinkler extends PointFeature {
 
 export function collectYardData(features, groups = []) {
   const collection = collectGeoJSON(features);
+  collection.schemaVersion = YARD_SCHEMA_VERSION;
+  collection.lifecycleFields = {
+    startDate: "date",
+    endDate: "date",
+  };
+  collection.featureTypes = listFeatureTypeSchemas();
   collection.groups = groups.map((group) => ({
     id: group.id,
     label: group.label,
@@ -276,4 +346,4 @@ export function collectYardData(features, groups = []) {
   return collection;
 }
 
-export { collectGeoJSON };
\ No newline at end of file
+export { collectGeoJSON };
diff --git a/src/main.js b/src/main.js
index d558fe7..c9b06ab 100644
--- a/src/main.js
+++ b/src/main.js
@@ -1,4 +1,5 @@
 import { yardGeoJSON } from "./data/yard.js";
+import { resolveFeatureLinks, resolveFeatureStyle } from "./lib/feature-types.js";
 
 const TILE_MAX_NATIVE_ZOOM = 19;
 const MAP_MAX_ZOOM = 24;
@@ -7,6 +8,7 @@ const map = L.map("map", {
   zoomControl: false,
   maxZoom: MAP_MAX_ZOOM,
 });
+const meterScaledIconMarkers = [];
 
 L.control
   .zoom({
@@ -20,26 +22,55 @@ L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
   attribution: '© OpenStreetMap',
 }).addTo(map);
 
-const geoJsonLayer = L.geoJSON(yardGeoJSON, {
+L.geoJSON(yardGeoJSON, {
   pointToLayer(feature, latlng) {
-    const style = feature.properties.style;
-    return L.circleMarker(latlng, {
-      radius: style.radius ?? 6,
+    const style = getStyle(feature);
+    const options = {
       color: style.color ?? "#333333",
       fillColor: style.fillColor ?? style.color ?? "#333333",
       fillOpacity: style.fillOpacity ?? 0.8,
       weight: style.weight ?? 2,
+    };
+
+    if (style.iconUrl) {
+      const marker = L.marker(latlng, {
+        icon: buildIcon(style, latlng),
+      });
+
+      if (typeof style.iconDiameterMeters === "number") {
+        meterScaledIconMarkers.push({ marker, style, latlng });
+      }
+
+      return marker;
+    }
+
+    if (typeof style.radiusMeters === "number") {
+      return L.circle(latlng, {
+        ...options,
+        radius: style.radiusMeters,
+      });
+    }
+
+    return L.circleMarker(latlng, {
+      ...options,
+      radius: style.radius ?? 6,
     });
   },
   style(feature) {
-    return feature.properties.style;
+    if (feature.geometry.type === "Point") {
+      return getPathStyle(feature);
+    }
+
+    return getStyle(feature);
   },
   onEachFeature(feature, layer) {
     layer.bindPopup(buildPopupContent(feature.properties));
   },
 }).addTo(map);
 
-map.fitBounds(geoJsonLayer.getBounds().pad(0.2), { maxZoom: MAP_MAX_ZOOM });
+map.fitBounds(getGeoJSONBounds(yardGeoJSON).pad(0.2), { maxZoom: MAP_MAX_ZOOM });
+updateMeterScaledIcons();
+map.on("zoomend", updateMeterScaledIcons);
 
 const legend = document.getElementById("legend");
 const legendEntries = summarizeTypes(yardGeoJSON.features);
@@ -66,10 +97,11 @@ function summarizeTypes(features) {
 
   for (const feature of features) {
     const key = feature.properties.kind;
+    const style = getStyle(feature);
     const existing = summary.get(key) ?? {
       label: feature.properties.label,
       count: 0,
-      color: feature.properties.style.color ?? "#333333",
+      color: style.color ?? "#333333",
     };
 
     existing.count += 1;
@@ -79,10 +111,96 @@ function summarizeTypes(features) {
   return [...summary.values()];
 }
 
+function getStyle(feature) {
+  return resolveFeatureStyle(feature.properties.kind, feature.properties.details);
+}
+
+function getPathStyle(feature) {
+  const {
+    iconAnchor,
+    iconDiameterMeters,
+    iconSize,
+    iconUrl,
+    popupAnchor,
+    radius,
+    radiusMeters,
+    ...style
+  } = getStyle(feature);
+  return style;
+}
+
+function buildIcon(style, latlng) {
+  const iconSize = getIconSize(style, latlng);
+
+  return L.icon({
+    iconUrl: style.iconUrl,
+    iconSize,
+    iconAnchor: [iconSize[0] / 2, iconSize[1] / 2],
+    popupAnchor: [0, -iconSize[1] / 2],
+  });
+}
+
+function updateMeterScaledIcons() {
+  for (const { marker, style, latlng } of meterScaledIconMarkers) {
+    marker.setIcon(buildIcon(style, latlng));
+  }
+}
+
+function getIconSize(style, latlng) {
+  if (typeof style.iconDiameterMeters !== "number") {
+    return style.iconSize ?? [24, 24];
+  }
+
+  if (typeof map.getZoom() !== "number") {
+    return style.iconSize ?? [24, 24];
+  }
+
+  const diameterPixels = style.iconDiameterMeters / getMetersPerPixel(latlng.lat);
+  return [diameterPixels, diameterPixels];
+}
+
+function getMetersPerPixel(lat) {
+  return (156543.03392 * Math.cos((lat * Math.PI) / 180)) / 2 ** map.getZoom();
+}
+
+function getGeoJSONBounds(collection) {
+  const bounds = L.latLngBounds();
+
+  for (const feature of collection.features) {
+    extendBounds(bounds, feature.geometry.coordinates);
+  }
+
+  return bounds;
+}
+
+function extendBounds(bounds, coordinates) {
+  if (typeof coordinates[0] === "number") {
+    const [lon, lat] = coordinates;
+    bounds.extend([lat, lon]);
+    return;
+  }
+
+  for (const coordinate of coordinates) {
+    extendBounds(bounds, coordinate);
+  }
+}
+
 function buildPopupContent(properties) {
-  const { label, name, details, parentId, groupIds } = properties;
-  const entries = Object.entries(details ?? {}).filter(([, value]) => value !== null && value !== undefined);
+  const { kind, label, name, startDate, endDate, details, parentId, groupIds } = properties;
+  const entries = Object.entries(details ?? {}).filter(
+    ([key, value]) => value !== null && value !== undefined && !key.endsWith("Url"),
+  );
+  const lifecycleEntries = [];
   const relationshipEntries = [];
+  const linkEntries = resolveFeatureLinks(kind, details);
+
+  if (startDate) {
+    lifecycleEntries.push(["startDate", startDate]);
+  }
+
+  if (endDate) {
+    lifecycleEntries.push(["endDate", endDate]);
+  }
 
   if (parentId) {
     relationshipEntries.push(["parentFeature", parentId]);
@@ -94,16 +212,19 @@ function buildPopupContent(properties) {
 
   const title = `${escapeHtml(name)}`;
   const subtitle = name === label ? "" : `
${escapeHtml(label)}
`; - const allEntries = [...entries, ...relationshipEntries]; - if (allEntries.length === 0) { + const allEntries = [...lifecycleEntries, ...entries, ...relationshipEntries]; + if (allEntries.length === 0 && linkEntries.length === 0) { return `${title}${subtitle}`; } const detailRows = allEntries .map(([key, value]) => `
${escapeHtml(formatDetailLabel(key))}: ${escapeHtml(formatDetailValue(key, value))}
`) .join(""); + const linkRows = linkEntries + .map((link) => `
${escapeHtml(link.label)}: ${escapeHtml(link.url)}
`) + .join(""); - return `${title}${subtitle}
${detailRows}
`; + return `${title}${subtitle}
${detailRows}${linkRows}
`; } function formatDetailLabel(key) { @@ -111,8 +232,20 @@ function formatDetailLabel(key) { canopyDiameterMeters: "Canopy diameter", heightMeters: "Height", plantedDate: "Planted", + startDate: "Start", + endDate: "End", + commonName: "Common name", + genus: "Genus", + cultivar: "Cultivar", + bloomColor: "Bloom color", + bloomSeason: "Bloom season", + daylilyDatabaseId: "Daylily database ID", + daylilyDatabaseUrl: "Daylily database URL", species: "Species", fenceType: "Fence type", + axis: "Axis", + offsetFeet: "Offset", + spacingFeet: "Spacing", note: "Note", parentFeature: "Parent", groups: "Groups", @@ -126,6 +259,10 @@ function formatDetailValue(key, value) { return `${value} m`; } + if (key === "offsetFeet" || key === "spacingFeet") { + return `${value} ft`; + } + return String(value); } @@ -136,4 +273,4 @@ function escapeHtml(value) { .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'"); -} \ No newline at end of file +} diff --git a/styles.css b/styles.css index f55f3ac..89152a8 100644 --- a/styles.css +++ b/styles.css @@ -125,6 +125,63 @@ main { font-family: "Iowan Old Style", "Palatino Linotype", "URW Palladio L", serif; } +.error-overlay { + position: fixed; + right: 1rem; + bottom: 1rem; + z-index: 10000; + width: min(42rem, calc(100vw - 2rem)); + max-height: min(28rem, calc(100vh - 2rem)); + overflow: auto; + border: 1px solid rgba(111, 23, 23, 0.32); + border-radius: 8px; + background: #fff7f4; + box-shadow: 0 18px 60px rgba(59, 20, 20, 0.22); + color: #331514; +} + +.error-overlay[hidden] { + display: none; +} + +.error-overlay-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.8rem 1rem; + border-bottom: 1px solid rgba(111, 23, 23, 0.18); + background: #f6d5ce; +} + +.error-overlay-actions { + display: flex; + gap: 0.45rem; +} + +.error-overlay button { + border: 1px solid rgba(111, 23, 23, 0.28); + border-radius: 6px; + padding: 0.35rem 0.55rem; + background: #fffaf8; + color: inherit; + font: inherit; + cursor: pointer; +} + +.error-overlay pre { + margin: 0; + border-radius: 0; + background: transparent; + white-space: pre-wrap; +} + +.error-overlay-copy-buffer { + position: fixed; + top: -100vh; + left: -100vw; +} + @media (max-width: 900px) { .app-shell { grid-template-columns: 1fr; @@ -133,4 +190,4 @@ main { #map { min-height: 65vh; } -} \ No newline at end of file +} diff --git a/vendor/leaflet/images/layers-2x.png b/vendor/leaflet/images/layers-2x.png new file mode 100644 index 0000000000000000000000000000000000000000..200c333dca9652ac4cba004d609e5af4eee168c1 GIT binary patch literal 1259 zcmVFhCYNy;#0irRPomHqW|G1C*;4?@4#E?jH>?v@U%cy?3dQAc-DchXVErpOh~ z-jbon+tNbnl6hoEb;)TVk+%hTDDi_G%i3*RZ&15!$Fjr^f;Ke&A@|?=`2&+{zr+3a z{D*=t(`AXyS%X7N z%a#RZw6vD^t_rnM`L4E>m=U&R!A-&}nZIi$BOPvkhrCuUe@BN~-lRD)f44;J%TwgE zcze8u!PQ_NR7?o(NylLXVTfDO zxs5=@|GsYEsNo4M#nT%N!UE(?dnS)t2+{ELYAFp*3=iF=|EQnTp`#vlSXuGVraYo? z+RCzXo6h3qA8{KG?S4nE(lM+;Eb4nT3XV;7gcAxUi5m)`k5tv}cPy()8ZR3TLW3I- zAS^}cq-IJvL7a4RgR!yk@~RT%$lA7{L5ES*hyx)M4(yxI$Ub(4f)K|^v1>zvwQY!_ zIrWw8q9GS^!Dp~}+?mbnB6jDF8mVlbQ!jFKDY;w=7;XO{9bq7>LXGK24WA`;rL)_Z z)&j}pbV(;6gY;VMhbxgvn`X;6x}VUEE-7 z%)7j-%t8S=ZL3yc)HbXDAqJZvBTPoiW_A-+a8m3_Z?v{DN7Tnr#O_VUMT0UBt$;p` zDh6JbGHN8JJ*JN%y2%msb97@_S>9!%Egwk;?PEkU9ntz&3uR}%Fj5d$JHQbQb3}a{ zSzFT^#n=VInPpcAS}CNxj?_ zVscANk5Cfz(51EI1pz};AWWb|kgbYNb4wCEGUn3+eMUMV?1-{=I4TlmLJMot@rd07 zZuo2hk1ccu{YmGkcYdWAVdk{Z4Nm?^cTD&}jGm+Q1SYIXMwmG*oO*83&#>l%nbR`G zhh=lZ%xIb7kU3#;TBbfECrnC9P=-XpL|TG2BoZdj61*XiFbW8?1Z_wp%#;>${SUIy V$8qr;L*)Pf002ovPDHLkV1hYLS~36t literal 0 HcmV?d00001 diff --git a/vendor/leaflet/images/layers.png b/vendor/leaflet/images/layers.png new file mode 100644 index 0000000000000000000000000000000000000000..1a72e5784b2b456eac5d7670738db80697af3377 GIT binary patch literal 696 zcmV;p0!RIcP)*@&l2<6p=!C&s@#ZL+%BQvF&b?w6S%wp=I>1QHj7AP5C)IWy#b znXXB;g;j=$a-tW89K%FbDceHVq&unY*Wx3L#=EGWH=rjqnp|4c_Ulec!ql3#G-5ZF zVlbBA@XP=)C8U&+Lrc)S4O5%1$&{(;7R^K(CSnvSr$v;+B$8q&7Bf|h$#PARo1^%M zf1H^nG-EiXVXr07OH(*8R)xa|FD;lXUlg_-%)~ZGsL2cX0NXaAzN2q%jqLRR6ruVk8`Jb7n#{`T;o@`F= z#3YcynIR^s83UNF3D!f5m#Mg)NJ24&Qfrqb&_z=yF;=B)#9Iq7u-@^O!(mW{D;qvr zPc)gVb%aowtS8m@ElL4A9G>w#ffQ~q{i&_i)*6f^)Sz|C?C>zb4Uo?H<-&Hz@a?J; z$ml@zGygWofb9$ZBj6aLjpLhsT2AzjOu=-*u_gSCUYnU^5s62$4H-fe}gSR(=wKRaTHh!@*b)YV6mo|a4Fn6Rgc&Rpk zvn_X|3VY?v=>nJ{slE^V1GaGWk}m@aIWGIpghbfPh8m@aIWEo_%AZI>==moIFVE^L=C zZJ91?mo03UEp3-BY?wBGur6$uD{Yr9Y?m%SHF8Fk1pc(Nva%QJ+{FLkalfypz3&M|||Fn`7|g3c~4(nXHKFmRnwn$J#_$xE8i z|Ns9!kC;(oC1qQk>LMp3_a2(odYyMT@>voX=UI)k>1cJdn;gjmJ-|6v4nb1Oryh)eQMwHP(i@!36%vGJyFK(JTj?Vb{{C=jx&)@1l zlFmnw%0`&bqruifkkHKC=vbiAM3&E`#Mv>2%tw;VK8?_|&E89cs{a1}$J*!f_xd-C z&F%B|oxRgPlh0F!txkxrQjNA`m9~?&&|jw4W0<`_iNHsX$VQXVK!B}Xkh4>av|f_8 zLY2?t?ejE=%(TnfV5iqOjm?d;&qI~ZGl|SzU77a)002XDQchC<95+*MjE@82?VLm= z3xf6%Vd@99z|q|-ua5l3kJxvZwan-8K1cPiwQAtlcNX~ZqLeoMB+a;7)WA|O#HOB% zg6SX;754xD1{Fy}K~#8Ntklac&zTpadXZ& zC*_=T&g7hfbI$R?v%9?sknIb97gJOJ=`-8YyS3ndqN+Jm+x33!p&Hc@@L$w))s2@N ztv~i}Emc?DykgwFWwma($8+~b>l?tqj$dh13R^nMZnva9 zn0Vflzv2Dvp`oVQw{Guby~i`JGbyBGTEC{y>yzCkg>K&CIeQ$u;lyQ+M{O~gEJ^)Z zrF3p)^>|uT;57}WY&IRwyOQ=dq%Az}_t=_hKowP!Z79q0;@Zu(SWEJJcHY+5T6I({ zw)wj*SNi4wrd+POUfZe4gF77vW?j zoFS}|r2n&$U9Y!S4VEOyN}OpZZi|?cr1VcE_tHsDQgp-ga(SwkBrkCm{|*-yb=}ZW zvcYvLvfA90TPn|!-TuYJV<6`}+RJeRgP3EA=qQcF9k0*#*{f&I_pjam%I6Dd#YE|G zqB!R}tW-K!wV1w+4JcFA_s6~=@9F&j8`u$-ifLN3vK;`lvaA-`jRn_}(8|)!3?-}I zvFi{H;@A$gEZYh?%|Qr_y#*UkOPjwiRCsJQ>mb6h5yGIk6C5_XA=8T?IBfm_?+P0; zhhUs)-(0R*H<&Kku(1>#cGtOpk&Z&kQcw&SJv-4VY<+;=8hYnoX zfNJMCa9)^5Z0;2dCUk;x-%#yS!I~Jr3pNuI!g_tHz!$hKwt1GL~sFvx)3u4TA zv>CLGdQtoZ7Du7ctJRfTqY;FPxs1G{ZJ?73D5J@OO{6BHcPbk{_mjg&p2QFeke%QI zlAJ-kvjuwy1<5D-6>su68A+i998aSZNnQX)+Q}6(GK-C%8G-!1bOJBONU{gT%IOOE z;Yk24YC@^lFW77>r6x7eS1Omc;8=GUp#&zLQ&L{ zv8$hGC`wp~$9pR>f%-_Ps3>YhzP(+vC(E*zr1CVO8ChN^MI-VGMX7+|(r!SGZ9gd5 zzO9sQd>sm|f1|X&oh=8lOzd6+ITvo zCXInR?>RZ#>Hb*PO=7dI!dZ(wY4O}ZGv zdfQFio7+0~PN*RFCZGM6@9-o~y*@?;k00NvOsw54t1^tt{*ATMs^2j}4Wp=4t3RH* z_+8b`F-{E=0sOgM<;VHTo!Ij3u zmmI`2?K7g(GOcGA)@h?$SW&pwHdtj1n57PLI8&6RHhx4R%Q7b z^JEqR)@06V!pbS*@D_ZyRMo_LlT}r{#sXOx4kM-V<_V{!5SSuM^SIVCA37|nY7LWQ zZA#B1h4l`6asz=Lvax_#GMRX|NF>=$=p{Qn0i@ExX1jGhy@B8a*_uR+ODEbVi8ObL zezG?azy>E~S~dl43&8<$(2H}P&*tuBdESUP83KQ?8B z?K(!uS>H1wlWQz;qOfB`T#TZ=EoSp~vZ5XtCvwm1h*Ex6mzTsn_y@_=xREIslV-%- zpdWkEzMjeNOGWrSM32gpBt27*O29NdhGzuDgYxcf`Jjjqw@B;Vmdb@fxdhCRi`Kg> zmUTr$=&@#i!%F4Q6mb&4QKfR^95KJ!<6~fqx-f^66AV!|ywG{6D^Vay-3b99>XOe# e-I|>x8~*?ZhF3snGbtJX0000cOl4 literal 0 HcmV?d00001 diff --git a/vendor/leaflet/images/marker-icon.png b/vendor/leaflet/images/marker-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..950edf24677ded147df13b26f91baa2b0fa70513 GIT binary patch literal 1466 zcmV;r1x5OaP)P001cn1^@s6z>|W`000GnNklGNuHDcIX17Zdjl&3`L?0sTjIws<{((Dh&g-s0<@jYQyl?D*X^?%13;ml^gy> ziMrY_^1WI=(g@LMizu=zCoA>C`6|QEq1eV92k*7m>G65*&@&6)aC&e}G zI)pf-Za|N`DT&Cn1J|o`19mumxW~hiKiKyc-P`S@q)rdTo84@QI@;0yXrG%9uhI>A zG5QHb6s4=<6xy{1 z@NMxEkryp{LS44%z$3lP^cX!9+2-;CTt3wM4(k*#C{aiIiLuB>jJj;KPhPzIC00bL zU3a#;aJld94lCW=`4&aAy8M7PY=HQ>O%$YEP4c4UY#CRxfgbE~(|uiI=YS8q;O9y6 zmIkXzR`}p7ti|PrM3a}WMnR=3NVnWdAAR>b9X@)DKL6=YsvmH%?I24wdq?Gh54_;# z$?_LvgjEdspdQlft#4CQ z`2Zyvy?*)N1Ftw|{_hakhG9WjS?Az@I@+IZ8JbWewR!XUK4&6346+d#~gsE0SY(LX8&JfY>Aj)RxGy96nwhs2rv zzW6pTnMpFkDSkT*a*6Dx|u@ds6ISVn0@^RmIsKZ5Y;bazbc;tTSq(kg(=481ODrPyNB6n z-$+U}(w$m6U6H$w17Bw+wDaFIe~GvNMYvnw31MpY0eQKT9l>SU``8k7w4)z!GZKMI z#_cEKq7k~i%nlK@6c-K?+R;B#5$?T#YpKD`t_4bAs^#E+@5QW$@OX3*`;(#{U^d-vY)&xEE>n5lYl&T?Amke9$Lam@{1K@O ze*LXqlKQHiv=gx+V^Cbb2?z@ISBQ*3amF;9UJ3SBg(N|710TLamQmYZ&Qjn2LuO<* zCZlB4n%@pc&7NNnY1}x+NWpHlq`OJEo|`aYN9<`RBUB+79g;>dgb6YlfN#kGL?lO_ z!6~M^7sOnbsUkKk<@Ysie&`G>ruxH&Mgy&8;i=A zB9OO!xR{AyODw>DS-q5YM{0ExFEAzt zm>RdS+ssW(-8|?xr0(?$vBVB*%(xDLtq3Hf0I5yFm<_g=W2`QWAax{1rWVH=I!VrP zs(rTFX@W#t$hXNvbgX`gK&^w_YD;CQ!B@e0QbLIWaKAXQe2-kkloo;{iF#6}z!4=W zi$giRj1{ zt;2w`VSCF#WE&*ev7jpsC=6175@(~nTE2;7M-L((0bH@yG}-TB$R~WXd?tA$s3|%y zA`9$sA(>F%J3ioz<-LJl*^o1|w84l>HBR`>3l9c8$5Xr@xCiIQ7{x$fMCzOk_-M=% z+{a_Q#;42`#KfUte@$NT77uaTz?b-fBe)1s5XE$yA79fm?KqM^VgLXD07*qoM6N<$ Ef<_J(9smFU literal 0 HcmV?d00001 diff --git a/vendor/leaflet/leaflet.css b/vendor/leaflet/leaflet.css new file mode 100644 index 0000000..2961b76 --- /dev/null +++ b/vendor/leaflet/leaflet.css @@ -0,0 +1,661 @@ +/* required styles */ + +.leaflet-pane, +.leaflet-tile, +.leaflet-marker-icon, +.leaflet-marker-shadow, +.leaflet-tile-container, +.leaflet-pane > svg, +.leaflet-pane > canvas, +.leaflet-zoom-box, +.leaflet-image-layer, +.leaflet-layer { + position: absolute; + left: 0; + top: 0; + } +.leaflet-container { + overflow: hidden; + } +.leaflet-tile, +.leaflet-marker-icon, +.leaflet-marker-shadow { + -webkit-user-select: none; + -moz-user-select: none; + user-select: none; + -webkit-user-drag: none; + } +/* Prevents IE11 from highlighting tiles in blue */ +.leaflet-tile::selection { + background: transparent; +} +/* Safari renders non-retina tile on retina better with this, but Chrome is worse */ +.leaflet-safari .leaflet-tile { + image-rendering: -webkit-optimize-contrast; + } +/* hack that prevents hw layers "stretching" when loading new tiles */ +.leaflet-safari .leaflet-tile-container { + width: 1600px; + height: 1600px; + -webkit-transform-origin: 0 0; + } +.leaflet-marker-icon, +.leaflet-marker-shadow { + display: block; + } +/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */ +/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */ +.leaflet-container .leaflet-overlay-pane svg { + max-width: none !important; + max-height: none !important; + } +.leaflet-container .leaflet-marker-pane img, +.leaflet-container .leaflet-shadow-pane img, +.leaflet-container .leaflet-tile-pane img, +.leaflet-container img.leaflet-image-layer, +.leaflet-container .leaflet-tile { + max-width: none !important; + max-height: none !important; + width: auto; + padding: 0; + } + +.leaflet-container img.leaflet-tile { + /* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */ + mix-blend-mode: plus-lighter; +} + +.leaflet-container.leaflet-touch-zoom { + -ms-touch-action: pan-x pan-y; + touch-action: pan-x pan-y; + } +.leaflet-container.leaflet-touch-drag { + -ms-touch-action: pinch-zoom; + /* Fallback for FF which doesn't support pinch-zoom */ + touch-action: none; + touch-action: pinch-zoom; +} +.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom { + -ms-touch-action: none; + touch-action: none; +} +.leaflet-container { + -webkit-tap-highlight-color: transparent; +} +.leaflet-container a { + -webkit-tap-highlight-color: rgba(51, 181, 229, 0.4); +} +.leaflet-tile { + filter: inherit; + visibility: hidden; + } +.leaflet-tile-loaded { + visibility: inherit; + } +.leaflet-zoom-box { + width: 0; + height: 0; + -moz-box-sizing: border-box; + box-sizing: border-box; + z-index: 800; + } +/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */ +.leaflet-overlay-pane svg { + -moz-user-select: none; + } + +.leaflet-pane { z-index: 400; } + +.leaflet-tile-pane { z-index: 200; } +.leaflet-overlay-pane { z-index: 400; } +.leaflet-shadow-pane { z-index: 500; } +.leaflet-marker-pane { z-index: 600; } +.leaflet-tooltip-pane { z-index: 650; } +.leaflet-popup-pane { z-index: 700; } + +.leaflet-map-pane canvas { z-index: 100; } +.leaflet-map-pane svg { z-index: 200; } + +.leaflet-vml-shape { + width: 1px; + height: 1px; + } +.lvml { + behavior: url(#default#VML); + display: inline-block; + position: absolute; + } + + +/* control positioning */ + +.leaflet-control { + position: relative; + z-index: 800; + pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */ + pointer-events: auto; + } +.leaflet-top, +.leaflet-bottom { + position: absolute; + z-index: 1000; + pointer-events: none; + } +.leaflet-top { + top: 0; + } +.leaflet-right { + right: 0; + } +.leaflet-bottom { + bottom: 0; + } +.leaflet-left { + left: 0; + } +.leaflet-control { + float: left; + clear: both; + } +.leaflet-right .leaflet-control { + float: right; + } +.leaflet-top .leaflet-control { + margin-top: 10px; + } +.leaflet-bottom .leaflet-control { + margin-bottom: 10px; + } +.leaflet-left .leaflet-control { + margin-left: 10px; + } +.leaflet-right .leaflet-control { + margin-right: 10px; + } + + +/* zoom and fade animations */ + +.leaflet-fade-anim .leaflet-popup { + opacity: 0; + -webkit-transition: opacity 0.2s linear; + -moz-transition: opacity 0.2s linear; + transition: opacity 0.2s linear; + } +.leaflet-fade-anim .leaflet-map-pane .leaflet-popup { + opacity: 1; + } +.leaflet-zoom-animated { + -webkit-transform-origin: 0 0; + -ms-transform-origin: 0 0; + transform-origin: 0 0; + } +svg.leaflet-zoom-animated { + will-change: transform; +} + +.leaflet-zoom-anim .leaflet-zoom-animated { + -webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1); + -moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1); + transition: transform 0.25s cubic-bezier(0,0,0.25,1); + } +.leaflet-zoom-anim .leaflet-tile, +.leaflet-pan-anim .leaflet-tile { + -webkit-transition: none; + -moz-transition: none; + transition: none; + } + +.leaflet-zoom-anim .leaflet-zoom-hide { + visibility: hidden; + } + + +/* cursors */ + +.leaflet-interactive { + cursor: pointer; + } +.leaflet-grab { + cursor: -webkit-grab; + cursor: -moz-grab; + cursor: grab; + } +.leaflet-crosshair, +.leaflet-crosshair .leaflet-interactive { + cursor: crosshair; + } +.leaflet-popup-pane, +.leaflet-control { + cursor: auto; + } +.leaflet-dragging .leaflet-grab, +.leaflet-dragging .leaflet-grab .leaflet-interactive, +.leaflet-dragging .leaflet-marker-draggable { + cursor: move; + cursor: -webkit-grabbing; + cursor: -moz-grabbing; + cursor: grabbing; + } + +/* marker & overlays interactivity */ +.leaflet-marker-icon, +.leaflet-marker-shadow, +.leaflet-image-layer, +.leaflet-pane > svg path, +.leaflet-tile-container { + pointer-events: none; + } + +.leaflet-marker-icon.leaflet-interactive, +.leaflet-image-layer.leaflet-interactive, +.leaflet-pane > svg path.leaflet-interactive, +svg.leaflet-image-layer.leaflet-interactive path { + pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */ + pointer-events: auto; + } + +/* visual tweaks */ + +.leaflet-container { + background: #ddd; + outline-offset: 1px; + } +.leaflet-container a { + color: #0078A8; + } +.leaflet-zoom-box { + border: 2px dotted #38f; + background: rgba(255,255,255,0.5); + } + + +/* general typography */ +.leaflet-container { + font-family: "Helvetica Neue", Arial, Helvetica, sans-serif; + font-size: 12px; + font-size: 0.75rem; + line-height: 1.5; + } + + +/* general toolbar styles */ + +.leaflet-bar { + box-shadow: 0 1px 5px rgba(0,0,0,0.65); + border-radius: 4px; + } +.leaflet-bar a { + background-color: #fff; + border-bottom: 1px solid #ccc; + width: 26px; + height: 26px; + line-height: 26px; + display: block; + text-align: center; + text-decoration: none; + color: black; + } +.leaflet-bar a, +.leaflet-control-layers-toggle { + background-position: 50% 50%; + background-repeat: no-repeat; + display: block; + } +.leaflet-bar a:hover, +.leaflet-bar a:focus { + background-color: #f4f4f4; + } +.leaflet-bar a:first-child { + border-top-left-radius: 4px; + border-top-right-radius: 4px; + } +.leaflet-bar a:last-child { + border-bottom-left-radius: 4px; + border-bottom-right-radius: 4px; + border-bottom: none; + } +.leaflet-bar a.leaflet-disabled { + cursor: default; + background-color: #f4f4f4; + color: #bbb; + } + +.leaflet-touch .leaflet-bar a { + width: 30px; + height: 30px; + line-height: 30px; + } +.leaflet-touch .leaflet-bar a:first-child { + border-top-left-radius: 2px; + border-top-right-radius: 2px; + } +.leaflet-touch .leaflet-bar a:last-child { + border-bottom-left-radius: 2px; + border-bottom-right-radius: 2px; + } + +/* zoom control */ + +.leaflet-control-zoom-in, +.leaflet-control-zoom-out { + font: bold 18px 'Lucida Console', Monaco, monospace; + text-indent: 1px; + } + +.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out { + font-size: 22px; + } + + +/* layers control */ + +.leaflet-control-layers { + box-shadow: 0 1px 5px rgba(0,0,0,0.4); + background: #fff; + border-radius: 5px; + } +.leaflet-control-layers-toggle { + background-image: url(images/layers.png); + width: 36px; + height: 36px; + } +.leaflet-retina .leaflet-control-layers-toggle { + background-image: url(images/layers-2x.png); + background-size: 26px 26px; + } +.leaflet-touch .leaflet-control-layers-toggle { + width: 44px; + height: 44px; + } +.leaflet-control-layers .leaflet-control-layers-list, +.leaflet-control-layers-expanded .leaflet-control-layers-toggle { + display: none; + } +.leaflet-control-layers-expanded .leaflet-control-layers-list { + display: block; + position: relative; + } +.leaflet-control-layers-expanded { + padding: 6px 10px 6px 6px; + color: #333; + background: #fff; + } +.leaflet-control-layers-scrollbar { + overflow-y: scroll; + overflow-x: hidden; + padding-right: 5px; + } +.leaflet-control-layers-selector { + margin-top: 2px; + position: relative; + top: 1px; + } +.leaflet-control-layers label { + display: block; + font-size: 13px; + font-size: 1.08333em; + } +.leaflet-control-layers-separator { + height: 0; + border-top: 1px solid #ddd; + margin: 5px -10px 5px -6px; + } + +/* Default icon URLs */ +.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */ + background-image: url(images/marker-icon.png); + } + + +/* attribution and scale controls */ + +.leaflet-container .leaflet-control-attribution { + background: #fff; + background: rgba(255, 255, 255, 0.8); + margin: 0; + } +.leaflet-control-attribution, +.leaflet-control-scale-line { + padding: 0 5px; + color: #333; + line-height: 1.4; + } +.leaflet-control-attribution a { + text-decoration: none; + } +.leaflet-control-attribution a:hover, +.leaflet-control-attribution a:focus { + text-decoration: underline; + } +.leaflet-attribution-flag { + display: inline !important; + vertical-align: baseline !important; + width: 1em; + height: 0.6669em; + } +.leaflet-left .leaflet-control-scale { + margin-left: 5px; + } +.leaflet-bottom .leaflet-control-scale { + margin-bottom: 5px; + } +.leaflet-control-scale-line { + border: 2px solid #777; + border-top: none; + line-height: 1.1; + padding: 2px 5px 1px; + white-space: nowrap; + -moz-box-sizing: border-box; + box-sizing: border-box; + background: rgba(255, 255, 255, 0.8); + text-shadow: 1px 1px #fff; + } +.leaflet-control-scale-line:not(:first-child) { + border-top: 2px solid #777; + border-bottom: none; + margin-top: -2px; + } +.leaflet-control-scale-line:not(:first-child):not(:last-child) { + border-bottom: 2px solid #777; + } + +.leaflet-touch .leaflet-control-attribution, +.leaflet-touch .leaflet-control-layers, +.leaflet-touch .leaflet-bar { + box-shadow: none; + } +.leaflet-touch .leaflet-control-layers, +.leaflet-touch .leaflet-bar { + border: 2px solid rgba(0,0,0,0.2); + background-clip: padding-box; + } + + +/* popup */ + +.leaflet-popup { + position: absolute; + text-align: center; + margin-bottom: 20px; + } +.leaflet-popup-content-wrapper { + padding: 1px; + text-align: left; + border-radius: 12px; + } +.leaflet-popup-content { + margin: 13px 24px 13px 20px; + line-height: 1.3; + font-size: 13px; + font-size: 1.08333em; + min-height: 1px; + } +.leaflet-popup-content p { + margin: 17px 0; + margin: 1.3em 0; + } +.leaflet-popup-tip-container { + width: 40px; + height: 20px; + position: absolute; + left: 50%; + margin-top: -1px; + margin-left: -20px; + overflow: hidden; + pointer-events: none; + } +.leaflet-popup-tip { + width: 17px; + height: 17px; + padding: 1px; + + margin: -10px auto 0; + pointer-events: auto; + + -webkit-transform: rotate(45deg); + -moz-transform: rotate(45deg); + -ms-transform: rotate(45deg); + transform: rotate(45deg); + } +.leaflet-popup-content-wrapper, +.leaflet-popup-tip { + background: white; + color: #333; + box-shadow: 0 3px 14px rgba(0,0,0,0.4); + } +.leaflet-container a.leaflet-popup-close-button { + position: absolute; + top: 0; + right: 0; + border: none; + text-align: center; + width: 24px; + height: 24px; + font: 16px/24px Tahoma, Verdana, sans-serif; + color: #757575; + text-decoration: none; + background: transparent; + } +.leaflet-container a.leaflet-popup-close-button:hover, +.leaflet-container a.leaflet-popup-close-button:focus { + color: #585858; + } +.leaflet-popup-scrolled { + overflow: auto; + } + +.leaflet-oldie .leaflet-popup-content-wrapper { + -ms-zoom: 1; + } +.leaflet-oldie .leaflet-popup-tip { + width: 24px; + margin: 0 auto; + + -ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)"; + filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678); + } + +.leaflet-oldie .leaflet-control-zoom, +.leaflet-oldie .leaflet-control-layers, +.leaflet-oldie .leaflet-popup-content-wrapper, +.leaflet-oldie .leaflet-popup-tip { + border: 1px solid #999; + } + + +/* div icon */ + +.leaflet-div-icon { + background: #fff; + border: 1px solid #666; + } + + +/* Tooltip */ +/* Base styles for the element that has a tooltip */ +.leaflet-tooltip { + position: absolute; + padding: 6px; + background-color: #fff; + border: 1px solid #fff; + border-radius: 3px; + color: #222; + white-space: nowrap; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + pointer-events: none; + box-shadow: 0 1px 3px rgba(0,0,0,0.4); + } +.leaflet-tooltip.leaflet-interactive { + cursor: pointer; + pointer-events: auto; + } +.leaflet-tooltip-top:before, +.leaflet-tooltip-bottom:before, +.leaflet-tooltip-left:before, +.leaflet-tooltip-right:before { + position: absolute; + pointer-events: none; + border: 6px solid transparent; + background: transparent; + content: ""; + } + +/* Directions */ + +.leaflet-tooltip-bottom { + margin-top: 6px; +} +.leaflet-tooltip-top { + margin-top: -6px; +} +.leaflet-tooltip-bottom:before, +.leaflet-tooltip-top:before { + left: 50%; + margin-left: -6px; + } +.leaflet-tooltip-top:before { + bottom: 0; + margin-bottom: -12px; + border-top-color: #fff; + } +.leaflet-tooltip-bottom:before { + top: 0; + margin-top: -12px; + margin-left: -6px; + border-bottom-color: #fff; + } +.leaflet-tooltip-left { + margin-left: -6px; +} +.leaflet-tooltip-right { + margin-left: 6px; +} +.leaflet-tooltip-left:before, +.leaflet-tooltip-right:before { + top: 50%; + margin-top: -6px; + } +.leaflet-tooltip-left:before { + right: 0; + margin-right: -12px; + border-left-color: #fff; + } +.leaflet-tooltip-right:before { + left: 0; + margin-left: -12px; + border-right-color: #fff; + } + +/* Printing */ + +@media print { + /* Prevent printers from removing background-images of controls. */ + .leaflet-control { + -webkit-print-color-adjust: exact; + print-color-adjust: exact; + } + } diff --git a/vendor/leaflet/leaflet.js b/vendor/leaflet/leaflet.js new file mode 100644 index 0000000..a3bf693 --- /dev/null +++ b/vendor/leaflet/leaflet.js @@ -0,0 +1,6 @@ +/* @preserve + * Leaflet 1.9.4, a JS library for interactive maps. https://leafletjs.com + * (c) 2010-2023 Vladimir Agafonkin, (c) 2010-2011 CloudMade + */ +!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports):"function"==typeof define&&define.amd?define(["exports"],e):e((t="undefined"!=typeof globalThis?globalThis:t||self).leaflet={})}(this,function(t){"use strict";function l(t){for(var e,i,n=1,o=arguments.length;n=this.min.x&&i.x<=this.max.x&&e.y>=this.min.y&&i.y<=this.max.y},intersects:function(t){t=_(t);var e=this.min,i=this.max,n=t.min,t=t.max,o=t.x>=e.x&&n.x<=i.x,t=t.y>=e.y&&n.y<=i.y;return o&&t},overlaps:function(t){t=_(t);var e=this.min,i=this.max,n=t.min,t=t.max,o=t.x>e.x&&n.xe.y&&n.y=n.lat&&i.lat<=o.lat&&e.lng>=n.lng&&i.lng<=o.lng},intersects:function(t){t=g(t);var e=this._southWest,i=this._northEast,n=t.getSouthWest(),t=t.getNorthEast(),o=t.lat>=e.lat&&n.lat<=i.lat,t=t.lng>=e.lng&&n.lng<=i.lng;return o&&t},overlaps:function(t){t=g(t);var e=this._southWest,i=this._northEast,n=t.getSouthWest(),t=t.getNorthEast(),o=t.lat>e.lat&&n.late.lng&&n.lng","http://www.w3.org/2000/svg"===(Wt.firstChild&&Wt.firstChild.namespaceURI));function y(t){return 0<=navigator.userAgent.toLowerCase().indexOf(t)}var b={ie:pt,ielt9:mt,edge:n,webkit:ft,android:gt,android23:vt,androidStock:yt,opera:xt,chrome:wt,gecko:bt,safari:Pt,phantom:Lt,opera12:o,win:Tt,ie3d:Mt,webkit3d:zt,gecko3d:_t,any3d:Ct,mobile:Zt,mobileWebkit:St,mobileWebkit3d:Et,msPointer:kt,pointer:Ot,touch:Bt,touchNative:At,mobileOpera:It,mobileGecko:Rt,retina:Nt,passiveEvents:Dt,canvas:jt,svg:Ht,vml:!Ht&&function(){try{var t=document.createElement("div"),e=(t.innerHTML='',t.firstChild);return e.style.behavior="url(#default#VML)",e&&"object"==typeof e.adj}catch(t){return!1}}(),inlineSvg:Wt,mac:0===navigator.platform.indexOf("Mac"),linux:0===navigator.platform.indexOf("Linux")},Ft=b.msPointer?"MSPointerDown":"pointerdown",Ut=b.msPointer?"MSPointerMove":"pointermove",Vt=b.msPointer?"MSPointerUp":"pointerup",qt=b.msPointer?"MSPointerCancel":"pointercancel",Gt={touchstart:Ft,touchmove:Ut,touchend:Vt,touchcancel:qt},Kt={touchstart:function(t,e){e.MSPOINTER_TYPE_TOUCH&&e.pointerType===e.MSPOINTER_TYPE_TOUCH&&O(e);ee(t,e)},touchmove:ee,touchend:ee,touchcancel:ee},Yt={},Xt=!1;function Jt(t,e,i){return"touchstart"!==e||Xt||(document.addEventListener(Ft,$t,!0),document.addEventListener(Ut,Qt,!0),document.addEventListener(Vt,te,!0),document.addEventListener(qt,te,!0),Xt=!0),Kt[e]?(i=Kt[e].bind(this,i),t.addEventListener(Gt[e],i,!1),i):(console.warn("wrong event specified:",e),u)}function $t(t){Yt[t.pointerId]=t}function Qt(t){Yt[t.pointerId]&&(Yt[t.pointerId]=t)}function te(t){delete Yt[t.pointerId]}function ee(t,e){if(e.pointerType!==(e.MSPOINTER_TYPE_MOUSE||"mouse")){for(var i in e.touches=[],Yt)e.touches.push(Yt[i]);e.changedTouches=[e],t(e)}}var ie=200;function ne(t,i){t.addEventListener("dblclick",i);var n,o=0;function e(t){var e;1!==t.detail?n=t.detail:"mouse"===t.pointerType||t.sourceCapabilities&&!t.sourceCapabilities.firesTouchEvents||((e=Ne(t)).some(function(t){return t instanceof HTMLLabelElement&&t.attributes.for})&&!e.some(function(t){return t instanceof HTMLInputElement||t instanceof HTMLSelectElement})||((e=Date.now())-o<=ie?2===++n&&i(function(t){var e,i,n={};for(i in t)e=t[i],n[i]=e&&e.bind?e.bind(t):e;return(t=n).type="dblclick",n.detail=2,n.isTrusted=!1,n._simulated=!0,n}(t)):n=1,o=e))}return t.addEventListener("click",e),{dblclick:i,simDblclick:e}}var oe,se,re,ae,he,le,ue=we(["transform","webkitTransform","OTransform","MozTransform","msTransform"]),ce=we(["webkitTransition","transition","OTransition","MozTransition","msTransition"]),de="webkitTransition"===ce||"OTransition"===ce?ce+"End":"transitionend";function _e(t){return"string"==typeof t?document.getElementById(t):t}function pe(t,e){var i=t.style[e]||t.currentStyle&&t.currentStyle[e];return"auto"===(i=i&&"auto"!==i||!document.defaultView?i:(t=document.defaultView.getComputedStyle(t,null))?t[e]:null)?null:i}function P(t,e,i){t=document.createElement(t);return t.className=e||"",i&&i.appendChild(t),t}function T(t){var e=t.parentNode;e&&e.removeChild(t)}function me(t){for(;t.firstChild;)t.removeChild(t.firstChild)}function fe(t){var e=t.parentNode;e&&e.lastChild!==t&&e.appendChild(t)}function ge(t){var e=t.parentNode;e&&e.firstChild!==t&&e.insertBefore(t,e.firstChild)}function ve(t,e){return void 0!==t.classList?t.classList.contains(e):0<(t=xe(t)).length&&new RegExp("(^|\\s)"+e+"(\\s|$)").test(t)}function M(t,e){var i;if(void 0!==t.classList)for(var n=F(e),o=0,s=n.length;othis.options.maxZoom)?this.setZoom(t):this},panInsideBounds:function(t,e){this._enforcingBounds=!0;var i=this.getCenter(),t=this._limitCenter(i,this._zoom,g(t));return i.equals(t)||this.panTo(t,e),this._enforcingBounds=!1,this},panInside:function(t,e){var i=m((e=e||{}).paddingTopLeft||e.padding||[0,0]),n=m(e.paddingBottomRight||e.padding||[0,0]),o=this.project(this.getCenter()),t=this.project(t),s=this.getPixelBounds(),i=_([s.min.add(i),s.max.subtract(n)]),s=i.getSize();return i.contains(t)||(this._enforcingBounds=!0,n=t.subtract(i.getCenter()),i=i.extend(t).getSize().subtract(s),o.x+=n.x<0?-i.x:i.x,o.y+=n.y<0?-i.y:i.y,this.panTo(this.unproject(o),e),this._enforcingBounds=!1),this},invalidateSize:function(t){if(!this._loaded)return this;t=l({animate:!1,pan:!0},!0===t?{animate:!0}:t);var e=this.getSize(),i=(this._sizeChanged=!0,this._lastCenter=null,this.getSize()),n=e.divideBy(2).round(),o=i.divideBy(2).round(),n=n.subtract(o);return n.x||n.y?(t.animate&&t.pan?this.panBy(n):(t.pan&&this._rawPanBy(n),this.fire("move"),t.debounceMoveend?(clearTimeout(this._sizeTimer),this._sizeTimer=setTimeout(a(this.fire,this,"moveend"),200)):this.fire("moveend")),this.fire("resize",{oldSize:e,newSize:i})):this},stop:function(){return this.setZoom(this._limitZoom(this._zoom)),this.options.zoomSnap||this.fire("viewreset"),this._stop()},locate:function(t){var e,i;return t=this._locateOptions=l({timeout:1e4,watch:!1},t),"geolocation"in navigator?(e=a(this._handleGeolocationResponse,this),i=a(this._handleGeolocationError,this),t.watch?this._locationWatchId=navigator.geolocation.watchPosition(e,i,t):navigator.geolocation.getCurrentPosition(e,i,t)):this._handleGeolocationError({code:0,message:"Geolocation not supported."}),this},stopLocate:function(){return navigator.geolocation&&navigator.geolocation.clearWatch&&navigator.geolocation.clearWatch(this._locationWatchId),this._locateOptions&&(this._locateOptions.setView=!1),this},_handleGeolocationError:function(t){var e;this._container._leaflet_id&&(e=t.code,t=t.message||(1===e?"permission denied":2===e?"position unavailable":"timeout"),this._locateOptions.setView&&!this._loaded&&this.fitWorld(),this.fire("locationerror",{code:e,message:"Geolocation error: "+t+"."}))},_handleGeolocationResponse:function(t){if(this._container._leaflet_id){var e,i,n=new v(t.coords.latitude,t.coords.longitude),o=n.toBounds(2*t.coords.accuracy),s=this._locateOptions,r=(s.setView&&(e=this.getBoundsZoom(o),this.setView(n,s.maxZoom?Math.min(e,s.maxZoom):e)),{latlng:n,bounds:o,timestamp:t.timestamp});for(i in t.coords)"number"==typeof t.coords[i]&&(r[i]=t.coords[i]);this.fire("locationfound",r)}},addHandler:function(t,e){return e&&(e=this[t]=new e(this),this._handlers.push(e),this.options[t]&&e.enable()),this},remove:function(){if(this._initEvents(!0),this.options.maxBounds&&this.off("moveend",this._panInsideMaxBounds),this._containerId!==this._container._leaflet_id)throw new Error("Map container is being reused by another instance");try{delete this._container._leaflet_id,delete this._containerId}catch(t){this._container._leaflet_id=void 0,this._containerId=void 0}for(var t in void 0!==this._locationWatchId&&this.stopLocate(),this._stop(),T(this._mapPane),this._clearControlPos&&this._clearControlPos(),this._resizeRequest&&(r(this._resizeRequest),this._resizeRequest=null),this._clearHandlers(),this._loaded&&this.fire("unload"),this._layers)this._layers[t].remove();for(t in this._panes)T(this._panes[t]);return this._layers=[],this._panes=[],delete this._mapPane,delete this._renderer,this},createPane:function(t,e){e=P("div","leaflet-pane"+(t?" leaflet-"+t.replace("Pane","")+"-pane":""),e||this._mapPane);return t&&(this._panes[t]=e),e},getCenter:function(){return this._checkIfLoaded(),this._lastCenter&&!this._moved()?this._lastCenter.clone():this.layerPointToLatLng(this._getCenterLayerPoint())},getZoom:function(){return this._zoom},getBounds:function(){var t=this.getPixelBounds();return new s(this.unproject(t.getBottomLeft()),this.unproject(t.getTopRight()))},getMinZoom:function(){return void 0===this.options.minZoom?this._layersMinZoom||0:this.options.minZoom},getMaxZoom:function(){return void 0===this.options.maxZoom?void 0===this._layersMaxZoom?1/0:this._layersMaxZoom:this.options.maxZoom},getBoundsZoom:function(t,e,i){t=g(t),i=m(i||[0,0]);var n=this.getZoom()||0,o=this.getMinZoom(),s=this.getMaxZoom(),r=t.getNorthWest(),t=t.getSouthEast(),i=this.getSize().subtract(i),t=_(this.project(t,n),this.project(r,n)).getSize(),r=b.any3d?this.options.zoomSnap:1,a=i.x/t.x,i=i.y/t.y,t=e?Math.max(a,i):Math.min(a,i),n=this.getScaleZoom(t,n);return r&&(n=Math.round(n/(r/100))*(r/100),n=e?Math.ceil(n/r)*r:Math.floor(n/r)*r),Math.max(o,Math.min(s,n))},getSize:function(){return this._size&&!this._sizeChanged||(this._size=new p(this._container.clientWidth||0,this._container.clientHeight||0),this._sizeChanged=!1),this._size.clone()},getPixelBounds:function(t,e){t=this._getTopLeftPoint(t,e);return new f(t,t.add(this.getSize()))},getPixelOrigin:function(){return this._checkIfLoaded(),this._pixelOrigin},getPixelWorldBounds:function(t){return this.options.crs.getProjectedBounds(void 0===t?this.getZoom():t)},getPane:function(t){return"string"==typeof t?this._panes[t]:t},getPanes:function(){return this._panes},getContainer:function(){return this._container},getZoomScale:function(t,e){var i=this.options.crs;return e=void 0===e?this._zoom:e,i.scale(t)/i.scale(e)},getScaleZoom:function(t,e){var i=this.options.crs,t=(e=void 0===e?this._zoom:e,i.zoom(t*i.scale(e)));return isNaN(t)?1/0:t},project:function(t,e){return e=void 0===e?this._zoom:e,this.options.crs.latLngToPoint(w(t),e)},unproject:function(t,e){return e=void 0===e?this._zoom:e,this.options.crs.pointToLatLng(m(t),e)},layerPointToLatLng:function(t){t=m(t).add(this.getPixelOrigin());return this.unproject(t)},latLngToLayerPoint:function(t){return this.project(w(t))._round()._subtract(this.getPixelOrigin())},wrapLatLng:function(t){return this.options.crs.wrapLatLng(w(t))},wrapLatLngBounds:function(t){return this.options.crs.wrapLatLngBounds(g(t))},distance:function(t,e){return this.options.crs.distance(w(t),w(e))},containerPointToLayerPoint:function(t){return m(t).subtract(this._getMapPanePos())},layerPointToContainerPoint:function(t){return m(t).add(this._getMapPanePos())},containerPointToLatLng:function(t){t=this.containerPointToLayerPoint(m(t));return this.layerPointToLatLng(t)},latLngToContainerPoint:function(t){return this.layerPointToContainerPoint(this.latLngToLayerPoint(w(t)))},mouseEventToContainerPoint:function(t){return De(t,this._container)},mouseEventToLayerPoint:function(t){return this.containerPointToLayerPoint(this.mouseEventToContainerPoint(t))},mouseEventToLatLng:function(t){return this.layerPointToLatLng(this.mouseEventToLayerPoint(t))},_initContainer:function(t){t=this._container=_e(t);if(!t)throw new Error("Map container not found.");if(t._leaflet_id)throw new Error("Map container is already initialized.");S(t,"scroll",this._onScroll,this),this._containerId=h(t)},_initLayout:function(){var t=this._container,e=(this._fadeAnimated=this.options.fadeAnimation&&b.any3d,M(t,"leaflet-container"+(b.touch?" leaflet-touch":"")+(b.retina?" leaflet-retina":"")+(b.ielt9?" leaflet-oldie":"")+(b.safari?" leaflet-safari":"")+(this._fadeAnimated?" leaflet-fade-anim":"")),pe(t,"position"));"absolute"!==e&&"relative"!==e&&"fixed"!==e&&"sticky"!==e&&(t.style.position="relative"),this._initPanes(),this._initControlPos&&this._initControlPos()},_initPanes:function(){var t=this._panes={};this._paneRenderers={},this._mapPane=this.createPane("mapPane",this._container),Z(this._mapPane,new p(0,0)),this.createPane("tilePane"),this.createPane("overlayPane"),this.createPane("shadowPane"),this.createPane("markerPane"),this.createPane("tooltipPane"),this.createPane("popupPane"),this.options.markerZoomAnimation||(M(t.markerPane,"leaflet-zoom-hide"),M(t.shadowPane,"leaflet-zoom-hide"))},_resetView:function(t,e,i){Z(this._mapPane,new p(0,0));var n=!this._loaded,o=(this._loaded=!0,e=this._limitZoom(e),this.fire("viewprereset"),this._zoom!==e);this._moveStart(o,i)._move(t,e)._moveEnd(o),this.fire("viewreset"),n&&this.fire("load")},_moveStart:function(t,e){return t&&this.fire("zoomstart"),e||this.fire("movestart"),this},_move:function(t,e,i,n){void 0===e&&(e=this._zoom);var o=this._zoom!==e;return this._zoom=e,this._lastCenter=t,this._pixelOrigin=this._getNewPixelOrigin(t),n?i&&i.pinch&&this.fire("zoom",i):((o||i&&i.pinch)&&this.fire("zoom",i),this.fire("move",i)),this},_moveEnd:function(t){return t&&this.fire("zoomend"),this.fire("moveend")},_stop:function(){return r(this._flyToFrame),this._panAnim&&this._panAnim.stop(),this},_rawPanBy:function(t){Z(this._mapPane,this._getMapPanePos().subtract(t))},_getZoomSpan:function(){return this.getMaxZoom()-this.getMinZoom()},_panInsideMaxBounds:function(){this._enforcingBounds||this.panInsideBounds(this.options.maxBounds)},_checkIfLoaded:function(){if(!this._loaded)throw new Error("Set map center and zoom first.")},_initEvents:function(t){this._targets={};var e=t?k:S;e((this._targets[h(this._container)]=this)._container,"click dblclick mousedown mouseup mouseover mouseout mousemove contextmenu keypress keydown keyup",this._handleDOMEvent,this),this.options.trackResize&&e(window,"resize",this._onResize,this),b.any3d&&this.options.transform3DLimit&&(t?this.off:this.on).call(this,"moveend",this._onMoveEnd)},_onResize:function(){r(this._resizeRequest),this._resizeRequest=x(function(){this.invalidateSize({debounceMoveend:!0})},this)},_onScroll:function(){this._container.scrollTop=0,this._container.scrollLeft=0},_onMoveEnd:function(){var t=this._getMapPanePos();Math.max(Math.abs(t.x),Math.abs(t.y))>=this.options.transform3DLimit&&this._resetView(this.getCenter(),this.getZoom())},_findEventTargets:function(t,e){for(var i,n=[],o="mouseout"===e||"mouseover"===e,s=t.target||t.srcElement,r=!1;s;){if((i=this._targets[h(s)])&&("click"===e||"preclick"===e)&&this._draggableMoved(i)){r=!0;break}if(i&&i.listens(e,!0)){if(o&&!We(s,t))break;if(n.push(i),o)break}if(s===this._container)break;s=s.parentNode}return n=n.length||r||o||!this.listens(e,!0)?n:[this]},_isClickDisabled:function(t){for(;t&&t!==this._container;){if(t._leaflet_disable_click)return!0;t=t.parentNode}},_handleDOMEvent:function(t){var e,i=t.target||t.srcElement;!this._loaded||i._leaflet_disable_events||"click"===t.type&&this._isClickDisabled(i)||("mousedown"===(e=t.type)&&Me(i),this._fireDOMEvent(t,e))},_mouseEvents:["click","dblclick","mouseover","mouseout","contextmenu"],_fireDOMEvent:function(t,e,i){"click"===t.type&&((a=l({},t)).type="preclick",this._fireDOMEvent(a,a.type,i));var n=this._findEventTargets(t,e);if(i){for(var o=[],s=0;sthis.options.zoomAnimationThreshold)return!1;var n=this.getZoomScale(e),n=this._getCenterOffset(t)._divideBy(1-1/n);if(!0!==i.animate&&!this.getSize().contains(n))return!1;x(function(){this._moveStart(!0,i.noMoveStart||!1)._animateZoom(t,e,!0)},this)}return!0},_animateZoom:function(t,e,i,n){this._mapPane&&(i&&(this._animatingZoom=!0,this._animateToCenter=t,this._animateToZoom=e,M(this._mapPane,"leaflet-zoom-anim")),this.fire("zoomanim",{center:t,zoom:e,noUpdate:n}),this._tempFireZoomEvent||(this._tempFireZoomEvent=this._zoom!==this._animateToZoom),this._move(this._animateToCenter,this._animateToZoom,void 0,!0),setTimeout(a(this._onZoomTransitionEnd,this),250))},_onZoomTransitionEnd:function(){this._animatingZoom&&(this._mapPane&&z(this._mapPane,"leaflet-zoom-anim"),this._animatingZoom=!1,this._move(this._animateToCenter,this._animateToZoom,void 0,!0),this._tempFireZoomEvent&&this.fire("zoom"),delete this._tempFireZoomEvent,this.fire("move"),this._moveEnd(!0))}});function Ue(t){return new B(t)}var B=et.extend({options:{position:"topright"},initialize:function(t){c(this,t)},getPosition:function(){return this.options.position},setPosition:function(t){var e=this._map;return e&&e.removeControl(this),this.options.position=t,e&&e.addControl(this),this},getContainer:function(){return this._container},addTo:function(t){this.remove(),this._map=t;var e=this._container=this.onAdd(t),i=this.getPosition(),t=t._controlCorners[i];return M(e,"leaflet-control"),-1!==i.indexOf("bottom")?t.insertBefore(e,t.firstChild):t.appendChild(e),this._map.on("unload",this.remove,this),this},remove:function(){return this._map&&(T(this._container),this.onRemove&&this.onRemove(this._map),this._map.off("unload",this.remove,this),this._map=null),this},_refocusOnMap:function(t){this._map&&t&&0",e=document.createElement("div");return e.innerHTML=t,e.firstChild},_addItem:function(t){var e,i=document.createElement("label"),n=this._map.hasLayer(t.layer),n=(t.overlay?((e=document.createElement("input")).type="checkbox",e.className="leaflet-control-layers-selector",e.defaultChecked=n):e=this._createRadioElement("leaflet-base-layers_"+h(this),n),this._layerControlInputs.push(e),e.layerId=h(t.layer),S(e,"click",this._onInputClick,this),document.createElement("span")),o=(n.innerHTML=" "+t.name,document.createElement("span"));return i.appendChild(o),o.appendChild(e),o.appendChild(n),(t.overlay?this._overlaysList:this._baseLayersList).appendChild(i),this._checkDisabledLayers(),i},_onInputClick:function(){if(!this._preventClick){var t,e,i=this._layerControlInputs,n=[],o=[];this._handlingClick=!0;for(var s=i.length-1;0<=s;s--)t=i[s],e=this._getLayer(t.layerId).layer,t.checked?n.push(e):t.checked||o.push(e);for(s=0;se.options.maxZoom},_expandIfNotCollapsed:function(){return this._map&&!this.options.collapsed&&this.expand(),this},_expandSafely:function(){var t=this._section,e=(this._preventClick=!0,S(t,"click",O),this.expand(),this);setTimeout(function(){k(t,"click",O),e._preventClick=!1})}})),qe=B.extend({options:{position:"topleft",zoomInText:'',zoomInTitle:"Zoom in",zoomOutText:'',zoomOutTitle:"Zoom out"},onAdd:function(t){var e="leaflet-control-zoom",i=P("div",e+" leaflet-bar"),n=this.options;return this._zoomInButton=this._createButton(n.zoomInText,n.zoomInTitle,e+"-in",i,this._zoomIn),this._zoomOutButton=this._createButton(n.zoomOutText,n.zoomOutTitle,e+"-out",i,this._zoomOut),this._updateDisabled(),t.on("zoomend zoomlevelschange",this._updateDisabled,this),i},onRemove:function(t){t.off("zoomend zoomlevelschange",this._updateDisabled,this)},disable:function(){return this._disabled=!0,this._updateDisabled(),this},enable:function(){return this._disabled=!1,this._updateDisabled(),this},_zoomIn:function(t){!this._disabled&&this._map._zoomthis._map.getMinZoom()&&this._map.zoomOut(this._map.options.zoomDelta*(t.shiftKey?3:1))},_createButton:function(t,e,i,n,o){i=P("a",i,n);return i.innerHTML=t,i.href="#",i.title=e,i.setAttribute("role","button"),i.setAttribute("aria-label",e),Ie(i),S(i,"click",Re),S(i,"click",o,this),S(i,"click",this._refocusOnMap,this),i},_updateDisabled:function(){var t=this._map,e="leaflet-disabled";z(this._zoomInButton,e),z(this._zoomOutButton,e),this._zoomInButton.setAttribute("aria-disabled","false"),this._zoomOutButton.setAttribute("aria-disabled","false"),!this._disabled&&t._zoom!==t.getMinZoom()||(M(this._zoomOutButton,e),this._zoomOutButton.setAttribute("aria-disabled","true")),!this._disabled&&t._zoom!==t.getMaxZoom()||(M(this._zoomInButton,e),this._zoomInButton.setAttribute("aria-disabled","true"))}}),Ge=(A.mergeOptions({zoomControl:!0}),A.addInitHook(function(){this.options.zoomControl&&(this.zoomControl=new qe,this.addControl(this.zoomControl))}),B.extend({options:{position:"bottomleft",maxWidth:100,metric:!0,imperial:!0},onAdd:function(t){var e="leaflet-control-scale",i=P("div",e),n=this.options;return this._addScales(n,e+"-line",i),t.on(n.updateWhenIdle?"moveend":"move",this._update,this),t.whenReady(this._update,this),i},onRemove:function(t){t.off(this.options.updateWhenIdle?"moveend":"move",this._update,this)},_addScales:function(t,e,i){t.metric&&(this._mScale=P("div",e,i)),t.imperial&&(this._iScale=P("div",e,i))},_update:function(){var t=this._map,e=t.getSize().y/2,t=t.distance(t.containerPointToLatLng([0,e]),t.containerPointToLatLng([this.options.maxWidth,e]));this._updateScales(t)},_updateScales:function(t){this.options.metric&&t&&this._updateMetric(t),this.options.imperial&&t&&this._updateImperial(t)},_updateMetric:function(t){var e=this._getRoundNum(t);this._updateScale(this._mScale,e<1e3?e+" m":e/1e3+" km",e/t)},_updateImperial:function(t){var e,i,t=3.2808399*t;5280'+(b.inlineSvg?' ':"")+"Leaflet"},initialize:function(t){c(this,t),this._attributions={}},onAdd:function(t){for(var e in(t.attributionControl=this)._container=P("div","leaflet-control-attribution"),Ie(this._container),t._layers)t._layers[e].getAttribution&&this.addAttribution(t._layers[e].getAttribution());return this._update(),t.on("layeradd",this._addAttribution,this),this._container},onRemove:function(t){t.off("layeradd",this._addAttribution,this)},_addAttribution:function(t){t.layer.getAttribution&&(this.addAttribution(t.layer.getAttribution()),t.layer.once("remove",function(){this.removeAttribution(t.layer.getAttribution())},this))},setPrefix:function(t){return this.options.prefix=t,this._update(),this},addAttribution:function(t){return t&&(this._attributions[t]||(this._attributions[t]=0),this._attributions[t]++,this._update()),this},removeAttribution:function(t){return t&&this._attributions[t]&&(this._attributions[t]--,this._update()),this},_update:function(){if(this._map){var t,e=[];for(t in this._attributions)this._attributions[t]&&e.push(t);var i=[];this.options.prefix&&i.push(this.options.prefix),e.length&&i.push(e.join(", ")),this._container.innerHTML=i.join(' ')}}}),n=(A.mergeOptions({attributionControl:!0}),A.addInitHook(function(){this.options.attributionControl&&(new Ke).addTo(this)}),B.Layers=Ve,B.Zoom=qe,B.Scale=Ge,B.Attribution=Ke,Ue.layers=function(t,e,i){return new Ve(t,e,i)},Ue.zoom=function(t){return new qe(t)},Ue.scale=function(t){return new Ge(t)},Ue.attribution=function(t){return new Ke(t)},et.extend({initialize:function(t){this._map=t},enable:function(){return this._enabled||(this._enabled=!0,this.addHooks()),this},disable:function(){return this._enabled&&(this._enabled=!1,this.removeHooks()),this},enabled:function(){return!!this._enabled}})),ft=(n.addTo=function(t,e){return t.addHandler(e,this),this},{Events:e}),Ye=b.touch?"touchstart mousedown":"mousedown",Xe=it.extend({options:{clickTolerance:3},initialize:function(t,e,i,n){c(this,n),this._element=t,this._dragStartTarget=e||t,this._preventOutline=i},enable:function(){this._enabled||(S(this._dragStartTarget,Ye,this._onDown,this),this._enabled=!0)},disable:function(){this._enabled&&(Xe._dragging===this&&this.finishDrag(!0),k(this._dragStartTarget,Ye,this._onDown,this),this._enabled=!1,this._moved=!1)},_onDown:function(t){var e,i;this._enabled&&(this._moved=!1,ve(this._element,"leaflet-zoom-anim")||(t.touches&&1!==t.touches.length?Xe._dragging===this&&this.finishDrag():Xe._dragging||t.shiftKey||1!==t.which&&1!==t.button&&!t.touches||((Xe._dragging=this)._preventOutline&&Me(this._element),Le(),re(),this._moving||(this.fire("down"),i=t.touches?t.touches[0]:t,e=Ce(this._element),this._startPoint=new p(i.clientX,i.clientY),this._startPos=Pe(this._element),this._parentScale=Ze(e),i="mousedown"===t.type,S(document,i?"mousemove":"touchmove",this._onMove,this),S(document,i?"mouseup":"touchend touchcancel",this._onUp,this)))))},_onMove:function(t){var e;this._enabled&&(t.touches&&1e&&(i.push(t[n]),o=n);oe.max.x&&(i|=2),t.ye.max.y&&(i|=8),i}function ri(t,e,i,n){var o=e.x,e=e.y,s=i.x-o,r=i.y-e,a=s*s+r*r;return 0this._layersMaxZoom&&this.setZoom(this._layersMaxZoom),void 0===this.options.minZoom&&this._layersMinZoom&&this.getZoom()t.y!=n.y>t.y&&t.x<(n.x-i.x)*(t.y-i.y)/(n.y-i.y)+i.x&&(l=!l);return l||yi.prototype._containsPoint.call(this,t,!0)}});var wi=ci.extend({initialize:function(t,e){c(this,e),this._layers={},t&&this.addData(t)},addData:function(t){var e,i,n,o=d(t)?t:t.features;if(o){for(e=0,i=o.length;es.x&&(r=i.x+a-s.x+o.x),i.x-r-n.x<(a=0)&&(r=i.x-n.x),i.y+e+o.y>s.y&&(a=i.y+e-s.y+o.y),i.y-a-n.y<0&&(a=i.y-n.y),(r||a)&&(this.options.keepInView&&(this._autopanning=!0),t.fire("autopanstart").panBy([r,a]))))},_getAnchor:function(){return m(this._source&&this._source._getPopupAnchor?this._source._getPopupAnchor():[0,0])}})),Ii=(A.mergeOptions({closePopupOnClick:!0}),A.include({openPopup:function(t,e,i){return this._initOverlay(Bi,t,e,i).openOn(this),this},closePopup:function(t){return(t=arguments.length?t:this._popup)&&t.close(),this}}),o.include({bindPopup:function(t,e){return this._popup=this._initOverlay(Bi,this._popup,t,e),this._popupHandlersAdded||(this.on({click:this._openPopup,keypress:this._onKeyPress,remove:this.closePopup,move:this._movePopup}),this._popupHandlersAdded=!0),this},unbindPopup:function(){return this._popup&&(this.off({click:this._openPopup,keypress:this._onKeyPress,remove:this.closePopup,move:this._movePopup}),this._popupHandlersAdded=!1,this._popup=null),this},openPopup:function(t){return this._popup&&(this instanceof ci||(this._popup._source=this),this._popup._prepareOpen(t||this._latlng)&&this._popup.openOn(this._map)),this},closePopup:function(){return this._popup&&this._popup.close(),this},togglePopup:function(){return this._popup&&this._popup.toggle(this),this},isPopupOpen:function(){return!!this._popup&&this._popup.isOpen()},setPopupContent:function(t){return this._popup&&this._popup.setContent(t),this},getPopup:function(){return this._popup},_openPopup:function(t){var e;this._popup&&this._map&&(Re(t),e=t.layer||t.target,this._popup._source!==e||e instanceof fi?(this._popup._source=e,this.openPopup(t.latlng)):this._map.hasLayer(this._popup)?this.closePopup():this.openPopup(t.latlng))},_movePopup:function(t){this._popup.setLatLng(t.latlng)},_onKeyPress:function(t){13===t.originalEvent.keyCode&&this._openPopup(t)}}),Ai.extend({options:{pane:"tooltipPane",offset:[0,0],direction:"auto",permanent:!1,sticky:!1,opacity:.9},onAdd:function(t){Ai.prototype.onAdd.call(this,t),this.setOpacity(this.options.opacity),t.fire("tooltipopen",{tooltip:this}),this._source&&(this.addEventParent(this._source),this._source.fire("tooltipopen",{tooltip:this},!0))},onRemove:function(t){Ai.prototype.onRemove.call(this,t),t.fire("tooltipclose",{tooltip:this}),this._source&&(this.removeEventParent(this._source),this._source.fire("tooltipclose",{tooltip:this},!0))},getEvents:function(){var t=Ai.prototype.getEvents.call(this);return this.options.permanent||(t.preclick=this.close),t},_initLayout:function(){var t="leaflet-tooltip "+(this.options.className||"")+" leaflet-zoom-"+(this._zoomAnimated?"animated":"hide");this._contentNode=this._container=P("div",t),this._container.setAttribute("role","tooltip"),this._container.setAttribute("id","leaflet-tooltip-"+h(this))},_updateLayout:function(){},_adjustPan:function(){},_setPosition:function(t){var e,i=this._map,n=this._container,o=i.latLngToContainerPoint(i.getCenter()),i=i.layerPointToContainerPoint(t),s=this.options.direction,r=n.offsetWidth,a=n.offsetHeight,h=m(this.options.offset),l=this._getAnchor(),i="top"===s?(e=r/2,a):"bottom"===s?(e=r/2,0):(e="center"===s?r/2:"right"===s?0:"left"===s?r:i.xthis.options.maxZoom||nthis.options.maxZoom||void 0!==this.options.minZoom&&oi.max.x)||!e.wrapLat&&(t.yi.max.y))return!1}return!this.options.bounds||(e=this._tileCoordsToBounds(t),g(this.options.bounds).overlaps(e))},_keyToBounds:function(t){return this._tileCoordsToBounds(this._keyToTileCoords(t))},_tileCoordsToNwSe:function(t){var e=this._map,i=this.getTileSize(),n=t.scaleBy(i),i=n.add(i);return[e.unproject(n,t.z),e.unproject(i,t.z)]},_tileCoordsToBounds:function(t){t=this._tileCoordsToNwSe(t),t=new s(t[0],t[1]);return t=this.options.noWrap?t:this._map.wrapLatLngBounds(t)},_tileCoordsToKey:function(t){return t.x+":"+t.y+":"+t.z},_keyToTileCoords:function(t){var t=t.split(":"),e=new p(+t[0],+t[1]);return e.z=+t[2],e},_removeTile:function(t){var e=this._tiles[t];e&&(T(e.el),delete this._tiles[t],this.fire("tileunload",{tile:e.el,coords:this._keyToTileCoords(t)}))},_initTile:function(t){M(t,"leaflet-tile");var e=this.getTileSize();t.style.width=e.x+"px",t.style.height=e.y+"px",t.onselectstart=u,t.onmousemove=u,b.ielt9&&this.options.opacity<1&&C(t,this.options.opacity)},_addTile:function(t,e){var i=this._getTilePos(t),n=this._tileCoordsToKey(t),o=this.createTile(this._wrapCoords(t),a(this._tileReady,this,t));this._initTile(o),this.createTile.length<2&&x(a(this._tileReady,this,t,null,o)),Z(o,i),this._tiles[n]={el:o,coords:t,current:!0},e.appendChild(o),this.fire("tileloadstart",{tile:o,coords:t})},_tileReady:function(t,e,i){e&&this.fire("tileerror",{error:e,tile:i,coords:t});var n=this._tileCoordsToKey(t);(i=this._tiles[n])&&(i.loaded=+new Date,this._map._fadeAnimated?(C(i.el,0),r(this._fadeFrame),this._fadeFrame=x(this._updateOpacity,this)):(i.active=!0,this._pruneTiles()),e||(M(i.el,"leaflet-tile-loaded"),this.fire("tileload",{tile:i.el,coords:t})),this._noTilesToLoad()&&(this._loading=!1,this.fire("load"),b.ielt9||!this._map._fadeAnimated?x(this._pruneTiles,this):setTimeout(a(this._pruneTiles,this),250)))},_getTilePos:function(t){return t.scaleBy(this.getTileSize()).subtract(this._level.origin)},_wrapCoords:function(t){var e=new p(this._wrapX?H(t.x,this._wrapX):t.x,this._wrapY?H(t.y,this._wrapY):t.y);return e.z=t.z,e},_pxBoundsToTileRange:function(t){var e=this.getTileSize();return new f(t.min.unscaleBy(e).floor(),t.max.unscaleBy(e).ceil().subtract([1,1]))},_noTilesToLoad:function(){for(var t in this._tiles)if(!this._tiles[t].loaded)return!1;return!0}});var Di=Ni.extend({options:{minZoom:0,maxZoom:18,subdomains:"abc",errorTileUrl:"",zoomOffset:0,tms:!1,zoomReverse:!1,detectRetina:!1,crossOrigin:!1,referrerPolicy:!1},initialize:function(t,e){this._url=t,(e=c(this,e)).detectRetina&&b.retina&&0')}}catch(t){}return function(t){return document.createElement("<"+t+' xmlns="urn:schemas-microsoft.com:vml" class="lvml">')}}(),zt={_initContainer:function(){this._container=P("div","leaflet-vml-container")},_update:function(){this._map._animatingZoom||(Wi.prototype._update.call(this),this.fire("update"))},_initPath:function(t){var e=t._container=Vi("shape");M(e,"leaflet-vml-shape "+(this.options.className||"")),e.coordsize="1 1",t._path=Vi("path"),e.appendChild(t._path),this._updateStyle(t),this._layers[h(t)]=t},_addPath:function(t){var e=t._container;this._container.appendChild(e),t.options.interactive&&t.addInteractiveTarget(e)},_removePath:function(t){var e=t._container;T(e),t.removeInteractiveTarget(e),delete this._layers[h(t)]},_updateStyle:function(t){var e=t._stroke,i=t._fill,n=t.options,o=t._container;o.stroked=!!n.stroke,o.filled=!!n.fill,n.stroke?(e=e||(t._stroke=Vi("stroke")),o.appendChild(e),e.weight=n.weight+"px",e.color=n.color,e.opacity=n.opacity,n.dashArray?e.dashStyle=d(n.dashArray)?n.dashArray.join(" "):n.dashArray.replace(/( *, *)/g," "):e.dashStyle="",e.endcap=n.lineCap.replace("butt","flat"),e.joinstyle=n.lineJoin):e&&(o.removeChild(e),t._stroke=null),n.fill?(i=i||(t._fill=Vi("fill")),o.appendChild(i),i.color=n.fillColor||n.color,i.opacity=n.fillOpacity):i&&(o.removeChild(i),t._fill=null)},_updateCircle:function(t){var e=t._point.round(),i=Math.round(t._radius),n=Math.round(t._radiusY||i);this._setPath(t,t._empty()?"M0 0":"AL "+e.x+","+e.y+" "+i+","+n+" 0,23592600")},_setPath:function(t,e){t._path.v=e},_bringToFront:function(t){fe(t._container)},_bringToBack:function(t){ge(t._container)}},qi=b.vml?Vi:ct,Gi=Wi.extend({_initContainer:function(){this._container=qi("svg"),this._container.setAttribute("pointer-events","none"),this._rootGroup=qi("g"),this._container.appendChild(this._rootGroup)},_destroyContainer:function(){T(this._container),k(this._container),delete this._container,delete this._rootGroup,delete this._svgSize},_update:function(){var t,e,i;this._map._animatingZoom&&this._bounds||(Wi.prototype._update.call(this),e=(t=this._bounds).getSize(),i=this._container,this._svgSize&&this._svgSize.equals(e)||(this._svgSize=e,i.setAttribute("width",e.x),i.setAttribute("height",e.y)),Z(i,t.min),i.setAttribute("viewBox",[t.min.x,t.min.y,e.x,e.y].join(" ")),this.fire("update"))},_initPath:function(t){var e=t._path=qi("path");t.options.className&&M(e,t.options.className),t.options.interactive&&M(e,"leaflet-interactive"),this._updateStyle(t),this._layers[h(t)]=t},_addPath:function(t){this._rootGroup||this._initContainer(),this._rootGroup.appendChild(t._path),t.addInteractiveTarget(t._path)},_removePath:function(t){T(t._path),t.removeInteractiveTarget(t._path),delete this._layers[h(t)]},_updatePath:function(t){t._project(),t._update()},_updateStyle:function(t){var e=t._path,t=t.options;e&&(t.stroke?(e.setAttribute("stroke",t.color),e.setAttribute("stroke-opacity",t.opacity),e.setAttribute("stroke-width",t.weight),e.setAttribute("stroke-linecap",t.lineCap),e.setAttribute("stroke-linejoin",t.lineJoin),t.dashArray?e.setAttribute("stroke-dasharray",t.dashArray):e.removeAttribute("stroke-dasharray"),t.dashOffset?e.setAttribute("stroke-dashoffset",t.dashOffset):e.removeAttribute("stroke-dashoffset")):e.setAttribute("stroke","none"),t.fill?(e.setAttribute("fill",t.fillColor||t.color),e.setAttribute("fill-opacity",t.fillOpacity),e.setAttribute("fill-rule",t.fillRule||"evenodd")):e.setAttribute("fill","none"))},_updatePoly:function(t,e){this._setPath(t,dt(t._parts,e))},_updateCircle:function(t){var e=t._point,i=Math.max(Math.round(t._radius),1),n="a"+i+","+(Math.max(Math.round(t._radiusY),1)||i)+" 0 1,0 ",e=t._empty()?"M0 0":"M"+(e.x-i)+","+e.y+n+2*i+",0 "+n+2*-i+",0 ";this._setPath(t,e)},_setPath:function(t,e){t._path.setAttribute("d",e)},_bringToFront:function(t){fe(t._path)},_bringToBack:function(t){ge(t._path)}});function Ki(t){return b.svg||b.vml?new Gi(t):null}b.vml&&Gi.include(zt),A.include({getRenderer:function(t){t=(t=t.options.renderer||this._getPaneRenderer(t.options.pane)||this.options.renderer||this._renderer)||(this._renderer=this._createRenderer());return this.hasLayer(t)||this.addLayer(t),t},_getPaneRenderer:function(t){var e;return"overlayPane"!==t&&void 0!==t&&(void 0===(e=this._paneRenderers[t])&&(e=this._createRenderer({pane:t}),this._paneRenderers[t]=e),e)},_createRenderer:function(t){return this.options.preferCanvas&&Ui(t)||Ki(t)}});var Yi=xi.extend({initialize:function(t,e){xi.prototype.initialize.call(this,this._boundsToLatLngs(t),e)},setBounds:function(t){return this.setLatLngs(this._boundsToLatLngs(t))},_boundsToLatLngs:function(t){return[(t=g(t)).getSouthWest(),t.getNorthWest(),t.getNorthEast(),t.getSouthEast()]}});Gi.create=qi,Gi.pointsToPath=dt,wi.geometryToLayer=bi,wi.coordsToLatLng=Li,wi.coordsToLatLngs=Ti,wi.latLngToCoords=Mi,wi.latLngsToCoords=zi,wi.getFeature=Ci,wi.asFeature=Zi,A.mergeOptions({boxZoom:!0});var _t=n.extend({initialize:function(t){this._map=t,this._container=t._container,this._pane=t._panes.overlayPane,this._resetStateTimeout=0,t.on("unload",this._destroy,this)},addHooks:function(){S(this._container,"mousedown",this._onMouseDown,this)},removeHooks:function(){k(this._container,"mousedown",this._onMouseDown,this)},moved:function(){return this._moved},_destroy:function(){T(this._pane),delete this._pane},_resetState:function(){this._resetStateTimeout=0,this._moved=!1},_clearDeferredResetState:function(){0!==this._resetStateTimeout&&(clearTimeout(this._resetStateTimeout),this._resetStateTimeout=0)},_onMouseDown:function(t){if(!t.shiftKey||1!==t.which&&1!==t.button)return!1;this._clearDeferredResetState(),this._resetState(),re(),Le(),this._startPoint=this._map.mouseEventToContainerPoint(t),S(document,{contextmenu:Re,mousemove:this._onMouseMove,mouseup:this._onMouseUp,keydown:this._onKeyDown},this)},_onMouseMove:function(t){this._moved||(this._moved=!0,this._box=P("div","leaflet-zoom-box",this._container),M(this._container,"leaflet-crosshair"),this._map.fire("boxzoomstart")),this._point=this._map.mouseEventToContainerPoint(t);var t=new f(this._point,this._startPoint),e=t.getSize();Z(this._box,t.min),this._box.style.width=e.x+"px",this._box.style.height=e.y+"px"},_finish:function(){this._moved&&(T(this._box),z(this._container,"leaflet-crosshair")),ae(),Te(),k(document,{contextmenu:Re,mousemove:this._onMouseMove,mouseup:this._onMouseUp,keydown:this._onKeyDown},this)},_onMouseUp:function(t){1!==t.which&&1!==t.button||(this._finish(),this._moved&&(this._clearDeferredResetState(),this._resetStateTimeout=setTimeout(a(this._resetState,this),0),t=new s(this._map.containerPointToLatLng(this._startPoint),this._map.containerPointToLatLng(this._point)),this._map.fitBounds(t).fire("boxzoomend",{boxZoomBounds:t})))},_onKeyDown:function(t){27===t.keyCode&&(this._finish(),this._clearDeferredResetState(),this._resetState())}}),Ct=(A.addInitHook("addHandler","boxZoom",_t),A.mergeOptions({doubleClickZoom:!0}),n.extend({addHooks:function(){this._map.on("dblclick",this._onDoubleClick,this)},removeHooks:function(){this._map.off("dblclick",this._onDoubleClick,this)},_onDoubleClick:function(t){var e=this._map,i=e.getZoom(),n=e.options.zoomDelta,i=t.originalEvent.shiftKey?i-n:i+n;"center"===e.options.doubleClickZoom?e.setZoom(i):e.setZoomAround(t.containerPoint,i)}})),Zt=(A.addInitHook("addHandler","doubleClickZoom",Ct),A.mergeOptions({dragging:!0,inertia:!0,inertiaDeceleration:3400,inertiaMaxSpeed:1/0,easeLinearity:.2,worldCopyJump:!1,maxBoundsViscosity:0}),n.extend({addHooks:function(){var t;this._draggable||(t=this._map,this._draggable=new Xe(t._mapPane,t._container),this._draggable.on({dragstart:this._onDragStart,drag:this._onDrag,dragend:this._onDragEnd},this),this._draggable.on("predrag",this._onPreDragLimit,this),t.options.worldCopyJump&&(this._draggable.on("predrag",this._onPreDragWrap,this),t.on("zoomend",this._onZoomEnd,this),t.whenReady(this._onZoomEnd,this))),M(this._map._container,"leaflet-grab leaflet-touch-drag"),this._draggable.enable(),this._positions=[],this._times=[]},removeHooks:function(){z(this._map._container,"leaflet-grab"),z(this._map._container,"leaflet-touch-drag"),this._draggable.disable()},moved:function(){return this._draggable&&this._draggable._moved},moving:function(){return this._draggable&&this._draggable._moving},_onDragStart:function(){var t,e=this._map;e._stop(),this._map.options.maxBounds&&this._map.options.maxBoundsViscosity?(t=g(this._map.options.maxBounds),this._offsetLimit=_(this._map.latLngToContainerPoint(t.getNorthWest()).multiplyBy(-1),this._map.latLngToContainerPoint(t.getSouthEast()).multiplyBy(-1).add(this._map.getSize())),this._viscosity=Math.min(1,Math.max(0,this._map.options.maxBoundsViscosity))):this._offsetLimit=null,e.fire("movestart").fire("dragstart"),e.options.inertia&&(this._positions=[],this._times=[])},_onDrag:function(t){var e,i;this._map.options.inertia&&(e=this._lastTime=+new Date,i=this._lastPos=this._draggable._absPos||this._draggable._newPos,this._positions.push(i),this._times.push(e),this._prunePositions(e)),this._map.fire("move",t).fire("drag",t)},_prunePositions:function(t){for(;1e.max.x&&(t.x=this._viscousLimit(t.x,e.max.x)),t.y>e.max.y&&(t.y=this._viscousLimit(t.y,e.max.y)),this._draggable._newPos=this._draggable._startPos.add(t))},_onPreDragWrap:function(){var t=this._worldWidth,e=Math.round(t/2),i=this._initialWorldOffset,n=this._draggable._newPos.x,o=(n-e+i)%t+e-i,n=(n+e+i)%t-e-i,t=Math.abs(o+i)e.getMaxZoom()&&1 Date: Fri, 26 Jun 2026 16:16:29 -0500 Subject: [PATCH 03/23] More features and plants added --- src/data/plants.js | 53 +++++++++++++++++++++++++++++++--------- src/lib/feature-types.js | 50 +++++++++++++++++++++++++++---------- src/main.js | 10 +++++--- 3 files changed, 85 insertions(+), 28 deletions(-) diff --git a/src/data/plants.js b/src/data/plants.js index c70a6c9..2cc2a93 100644 --- a/src/data/plants.js +++ b/src/data/plants.js @@ -48,26 +48,57 @@ const flowerBeds = [ ]; const daylilies = [ - new Daylily("You Had Me At Merlot", { - daylilyDatabaseId: "194128", - daylilyDatabaseUrl: "https://daylilydatabase.org/detail.php?id=194128", + new Daylily("Joan Derifield", { + gardenOrgId: "4765", + }).add(geo.offset(lotCorner), geo.east(geo.ft(112)), geo.south(geo.ft(12))), + new Daylily("Predatory Flamingo", { + gardenOrgId: "61201", + }).add(geo.offset(lotCorner), geo.east(geo.ft(112)), geo.south(geo.ft(14))), + new Daylily("You Had Me at Hello", { + gardenOrgId: "235117", }).add(geo.offset(lotCorner), geo.east(geo.ft(28)), geo.south(geo.ft(45))), new Daylily("Mapping North Dakota", { - daylilyDatabaseId: "158640", - daylilyDatabaseUrl: "https://daylilydatabase.org/detail.php?id=158640", + gardenOrgId: "56003", }).add(geo.offset(lotCorner), geo.east(geo.ft(28)), geo.south(geo.ft(47))), - new Daylily("Daylily", { - daylilyDatabaseId: "", - daylilyDatabaseUrl: "", - }).add(geo.offset(lotCorner), geo.east(geo.ft(31)), geo.south(geo.ft(47))), + new Daylily("Daylily").add(geo.offset(lotCorner), geo.east(geo.ft(31)), geo.south(geo.ft(47))), ]; const trees = [ - new Tree("Japanese Empress Elm", { - species: "Japanese Empress Elm", + new Tree("Northern Empress Japanese Elm", { + species: "Northern Empress Japanese Elm", canopyDiameterMeters: 6, heightMeters: 7, }).add(geo.offset(lotCorner), geo.east(geo.ft(9)), geo.south(geo.ft(16))), + new Tree("Hot Wings Maple", { + species: "Hot Wings Maple", + canopyDiameterMeters: 5, + heightMeters: 5, + gardenOrgId: "536120", + }).add(geo.offset(lotCorner), geo.east(geo.ft(60)), geo.south(geo.ft(40))), + new Tree("Prairie Expedition American Elm", { + species: "Prairie Expedition American Elm", + canopyDiameterMeters: 5, + heightMeters: 5, + gardenOrgId: "736267", + }).add(geo.offset(lotCorner), geo.east(geo.ft(95)), geo.south(geo.ft(15))), + new Tree("Dwarf Korean Lilac", { + species: "Dwarf Korean Lilac", + canopyDiameterMeters: 1.75, + heightMeters: 2.5, + gardenOrgId: "79137", + }).add(geo.offset(lotCorner), geo.east(geo.ft(3)), geo.south(geo.ft(3))), + new Tree("Dwarf Korean Lilac", { + species: "Dwarf Korean Lilac", + canopyDiameterMeters: 1.75, + heightMeters: 2.5, + gardenOrgId: "79137", + }).add(geo.offset(lotCorner), geo.east(geo.ft(3)), geo.south(geo.ft(20))), + new Tree("Dwarf Korean Lilac", { + species: "Dwarf Korean Lilac", + canopyDiameterMeters: 1.75, + heightMeters: 2.5, + gardenOrgId: "79137", + }).add(geo.offset(lotCorner), geo.east(geo.ft(3)), geo.south(geo.ft(37))), new Tree("Boulevard Linden", { species: "Boulevard Linden", canopyDiameterMeters: 3, diff --git a/src/lib/feature-types.js b/src/lib/feature-types.js index 6a1b4f9..b328166 100644 --- a/src/lib/feature-types.js +++ b/src/lib/feature-types.js @@ -127,9 +127,19 @@ export const featureTypes = { genus: "string", species: "string", cultivar: "string", + gardenOrgId: "string", plantedDate: "date", note: "string", }, + externalLinks: [ + { + label: "Garden.org", + url: (details) => + details.gardenOrgId + ? `https://garden.org/plants/view/${encodeURIComponent(details.gardenOrgId)}/` + : null, + }, + ], style: { color: "#3d7140", weight: 2, @@ -170,17 +180,9 @@ export const featureTypes = { cultivar: "string", bloomColor: "string", bloomSeason: "string", - daylilyDatabaseId: "string", - daylilyDatabaseUrl: "url", plantedDate: "date", note: "string", }, - externalLinks: [ - { - label: "Daylily database", - url: (details) => details.daylilyDatabaseUrl ?? null, - }, - ], style: { color: "#9f5e1d", weight: 2, @@ -267,8 +269,8 @@ export function listFeatureTypeSchemas() { label: definition.label, parentKind: definition.parentKind ?? null, geometryType: definition.geometryType, - detailFields: { ...definition.detailFields }, - externalLinks: (definition.externalLinks ?? []).map((link) => ({ + detailFields: getInheritedDetailFields(kind), + externalLinks: getInheritedExternalLinks(kind).map((link) => ({ label: link.label, })), })); @@ -280,12 +282,34 @@ export function resolveFeatureStyle(kind, details = {}) { } export function resolveFeatureLinks(kind, details = {}) { - const { externalLinks = [] } = getFeatureType(kind); - - return externalLinks + return getInheritedExternalLinks(kind) .map((link) => ({ label: link.label, url: link.url(details), })) .filter((link) => link.url); } + +function getFeatureTypeLineage(kind) { + const lineage = []; + let currentKind = kind; + + while (currentKind) { + const definition = getFeatureType(currentKind); + lineage.unshift(definition); + currentKind = definition.parentKind; + } + + return lineage; +} + +function getInheritedDetailFields(kind) { + return Object.assign( + {}, + ...getFeatureTypeLineage(kind).map((definition) => definition.detailFields ?? {}), + ); +} + +function getInheritedExternalLinks(kind) { + return getFeatureTypeLineage(kind).flatMap((definition) => definition.externalLinks ?? []); +} diff --git a/src/main.js b/src/main.js index c9b06ab..69668b0 100644 --- a/src/main.js +++ b/src/main.js @@ -188,7 +188,7 @@ function extendBounds(bounds, coordinates) { function buildPopupContent(properties) { const { kind, label, name, startDate, endDate, details, parentId, groupIds } = properties; const entries = Object.entries(details ?? {}).filter( - ([key, value]) => value !== null && value !== undefined && !key.endsWith("Url"), + ([key, value]) => value !== null && value !== undefined && shouldShowDetailField(key), ); const lifecycleEntries = []; const relationshipEntries = []; @@ -221,12 +221,16 @@ function buildPopupContent(properties) { .map(([key, value]) => `
${escapeHtml(formatDetailLabel(key))}: ${escapeHtml(formatDetailValue(key, value))}
`) .join(""); const linkRows = linkEntries - .map((link) => `
${escapeHtml(link.label)}: ${escapeHtml(link.url)}
`) + .map((link) => ``) .join(""); return `${title}${subtitle}
${detailRows}${linkRows}
`; } +function shouldShowDetailField(key) { + return !key.endsWith("Url") && key !== "gardenOrgId"; +} + function formatDetailLabel(key) { const labels = { canopyDiameterMeters: "Canopy diameter", @@ -239,8 +243,6 @@ function formatDetailLabel(key) { cultivar: "Cultivar", bloomColor: "Bloom color", bloomSeason: "Bloom season", - daylilyDatabaseId: "Daylily database ID", - daylilyDatabaseUrl: "Daylily database URL", species: "Species", fenceType: "Fence type", axis: "Axis", From 1354368ddc9e8fd39226ea66c87c7ea08ee1ad0f Mon Sep 17 00:00:00 2001 From: Aaron Axvig Date: Mon, 29 Jun 2026 13:52:20 -0500 Subject: [PATCH 04/23] Adding plants --- src/assets/icons/flower.svg | 33 ++++++++++++++++ src/data/plants.js | 77 ++++++++++++++++++++++++++++++++----- src/lib/feature-types.js | 3 +- 3 files changed, 102 insertions(+), 11 deletions(-) create mode 100644 src/assets/icons/flower.svg diff --git a/src/assets/icons/flower.svg b/src/assets/icons/flower.svg new file mode 100644 index 0000000..e194e0e --- /dev/null +++ b/src/assets/icons/flower.svg @@ -0,0 +1,33 @@ + + Flower + Abstract flower with paired leaves and a dahlia-like bloom. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/data/plants.js b/src/data/plants.js index 2cc2a93..e7f8191 100644 --- a/src/data/plants.js +++ b/src/data/plants.js @@ -1,4 +1,4 @@ -import { Daylily, defineGroup, FlowerBed, Tree } from "../lib/yard-features.js"; +import { Daylily, defineGroup, Flower, FlowerBed, Tree } from "../lib/yard-features.js"; import * as geo from "../lib/geometry.js"; import { lotCorner } from "./anchors.js"; @@ -48,12 +48,42 @@ const flowerBeds = [ ]; const daylilies = [ + new Daylily("Not sure", { + gardenOrgId: "4765", + }).add(geo.offset(lotCorner), geo.east(geo.ft(112)), geo.south(geo.ft(2))), + new Daylily("Wayside King Royale", { + gardenOrgId: "5090", + }).add(geo.offset(lotCorner), geo.east(geo.ft(112)), geo.south(geo.ft(4))), new Daylily("Joan Derifield", { gardenOrgId: "4765", - }).add(geo.offset(lotCorner), geo.east(geo.ft(112)), geo.south(geo.ft(12))), + }).add(geo.offset(lotCorner), geo.east(geo.ft(112)), geo.south(geo.ft(6))), new Daylily("Predatory Flamingo", { gardenOrgId: "61201", - }).add(geo.offset(lotCorner), geo.east(geo.ft(112)), geo.south(geo.ft(14))), + }).add(geo.offset(lotCorner), geo.east(geo.ft(112)), geo.south(geo.ft(8))), + new Daylily("Orange Daylily", { + gardenOrgId: "48484", + }).add(geo.offset(lotCorner), geo.east(geo.ft(142)), geo.south(geo.ft(24))), + new Daylily("Orange Daylily", { + gardenOrgId: "48484", + }).add(geo.offset(lotCorner), geo.east(geo.ft(142)), geo.south(geo.ft(26))), + new Daylily("Orange Daylily", { + gardenOrgId: "48484", + }).add(geo.offset(lotCorner), geo.east(geo.ft(142)), geo.south(geo.ft(28))), + new Daylily("Orange Daylily", { + gardenOrgId: "48484", + }).add(geo.offset(lotCorner), geo.east(geo.ft(142)), geo.south(geo.ft(30))), + new Daylily("Orange Daylily", { + gardenOrgId: "48484", + }).add(geo.offset(lotCorner), geo.east(geo.ft(142)), geo.south(geo.ft(32))), + new Daylily("Orange Daylily", { + gardenOrgId: "48484", + }).add(geo.offset(lotCorner), geo.east(geo.ft(142)), geo.south(geo.ft(34))), + new Daylily("Orange Daylily", { + gardenOrgId: "48484", + }).add(geo.offset(lotCorner), geo.east(geo.ft(142)), geo.south(geo.ft(36))), + new Daylily("Orange Daylily", { + gardenOrgId: "48484", + }).add(geo.offset(lotCorner), geo.east(geo.ft(142)), geo.south(geo.ft(38))), new Daylily("You Had Me at Hello", { gardenOrgId: "235117", }).add(geo.offset(lotCorner), geo.east(geo.ft(28)), geo.south(geo.ft(45))), @@ -63,6 +93,33 @@ const daylilies = [ new Daylily("Daylily").add(geo.offset(lotCorner), geo.east(geo.ft(31)), geo.south(geo.ft(47))), ]; +const flowers = [ + new Flower("Purple Coneflower", { + gardenOrgId: "71445", + }).add(geo.offset(lotCorner), geo.east(geo.ft(111.5)), geo.south(geo.ft(15))), + new Flower("Purple Coneflower", { + gardenOrgId: "71445", + }).add(geo.offset(lotCorner), geo.east(geo.ft(111.5)), geo.south(geo.ft(17))), + new Flower("Purple Coneflower", { + gardenOrgId: "71445", + }).add(geo.offset(lotCorner), geo.east(geo.ft(112.5)), geo.south(geo.ft(16))), + new Flower("Purple Coneflower", { + gardenOrgId: "71445", + }).add(geo.offset(lotCorner), geo.east(geo.ft(112.5)), geo.south(geo.ft(14))), + new Flower("Tiny Tortuga Turtlehead", { + gardenOrgId: "697986", + }).add(geo.offset(lotCorner), geo.east(geo.ft(3)), geo.south(geo.ft(8))), + new Flower("Tiny Tortuga Turtlehead", { + gardenOrgId: "697986", + }).add(geo.offset(lotCorner), geo.east(geo.ft(3)), geo.south(geo.ft(17))), + new Flower("Peony", { + gardenOrgId: "697986", + }).add(geo.offset(lotCorner), geo.east(geo.ft(19)), geo.south(geo.ft(1.5))), + new Flower("Peony", { + gardenOrgId: "697986", + }).add(geo.offset(lotCorner), geo.east(geo.ft(75)), geo.south(geo.ft(2))), +]; + const trees = [ new Tree("Northern Empress Japanese Elm", { species: "Northern Empress Japanese Elm", @@ -140,13 +197,13 @@ const trees = [ canopyDiameterMeters: 1.5, heightMeters: 2.5, }).inGroup(juniperTreesGroup).add(geo.lngLat(-100.8987812086838, 46.82671120797719)), - new Tree("Offset Test Tree", { - species: "Test tree", - canopyDiameterMeters: 4, - heightMeters: 5, - note: "70 feet east of top-left lot corner using assumedNorth", - }).add(geo.offset(lotCorner), geo.east(geo.ft(70))), + new Tree("Black Currant", { + species: "Black Currant", + canopyDiameterMeters: 2, + heightMeters: 2, + gardenOrgId: "87767", + }).add(geo.offset(lotCorner), geo.east(geo.ft(107)), geo.south(geo.ft(3))), ]; -export const plantFeatures = [...flowerBeds, ...daylilies, ...trees]; +export const plantFeatures = [...flowerBeds, ...daylilies, ...flowers, ...trees]; export const plantGroups = [juniperTreesGroup]; diff --git a/src/lib/feature-types.js b/src/lib/feature-types.js index b328166..1148b75 100644 --- a/src/lib/feature-types.js +++ b/src/lib/feature-types.js @@ -165,9 +165,10 @@ export const featureTypes = { style: { color: "#9c3f74", weight: 2, - radius: 5, fillColor: "#d979aa", fillOpacity: 0.85, + iconUrl: "./src/assets/icons/flower.svg", + iconDiameterMeters: 0.4, }, }, daylily: { From 4d8254df9001f7dd58d2ea69db4067545ab2aa8c Mon Sep 17 00:00:00 2001 From: Aaron Axvig Date: Mon, 29 Jun 2026 13:57:40 -0500 Subject: [PATCH 05/23] Smoother zooming --- src/main.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/main.js b/src/main.js index 69668b0..9f32044 100644 --- a/src/main.js +++ b/src/main.js @@ -3,10 +3,15 @@ import { resolveFeatureLinks, resolveFeatureStyle } from "./lib/feature-types.js const TILE_MAX_NATIVE_ZOOM = 19; const MAP_MAX_ZOOM = 24; +const MAP_ZOOM_STEP = 0.25; +const WHEEL_PIXELS_PER_ZOOM_LEVEL = 240; const map = L.map("map", { zoomControl: false, maxZoom: MAP_MAX_ZOOM, + zoomSnap: MAP_ZOOM_STEP, + zoomDelta: MAP_ZOOM_STEP, + wheelPxPerZoomLevel: WHEEL_PIXELS_PER_ZOOM_LEVEL, }); const meterScaledIconMarkers = []; @@ -70,6 +75,7 @@ L.geoJSON(yardGeoJSON, { map.fitBounds(getGeoJSONBounds(yardGeoJSON).pad(0.2), { maxZoom: MAP_MAX_ZOOM }); updateMeterScaledIcons(); +map.on("zoom", updateMeterScaledIcons); map.on("zoomend", updateMeterScaledIcons); const legend = document.getElementById("legend"); From 494db48e86cc220dcd36c5cbaf0ccefd78a7efef Mon Sep 17 00:00:00 2001 From: Aaron Axvig Date: Mon, 29 Jun 2026 14:46:51 -0500 Subject: [PATCH 06/23] Timeline added --- AGENTS.md | 8 +- README.md | 4 +- src/lib/feature-types.js | 5 - src/lib/yard-features.js | 30 ++--- src/main.js | 244 ++++++++++++++++++++++++++++----------- styles.css | 50 ++++++++ 6 files changed, 248 insertions(+), 93 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d358fb5..c645ce0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,8 +24,8 @@ Core files: Top-level exported data shape from `src/data/yard.js`: - `type: "FeatureCollection"` -- `schemaVersion: 1` -- `lifecycleFields: { startDate: "date", endDate: "date" }` +- `schemaVersion: 2` +- `lifecycleFields: { plantedDate: "date", removedDate: "date" }` - `featureTypes: [...]` - `features: [...]` - `groups: [...]` @@ -36,8 +36,8 @@ Each feature currently exports: - `properties.kind` - `properties.label` - `properties.name` -- `properties.startDate` -- `properties.endDate` +- `properties.plantedDate` +- `properties.removedDate` - `properties.parentId` - `properties.groupIds` - `properties.details` diff --git a/README.md b/README.md index 9c093b0..0624186 100644 --- a/README.md +++ b/README.md @@ -38,9 +38,9 @@ The typed feature registry lives in `src/lib/feature-types.js`, feature classes Local `north`, `south`, `east`, and `west` offsets are rotated per anchor. In this example, `topLeftLotCorner` in `src/data/anchors.js` carries `assumedNorthClockwiseDegrees` from `src/data/settings.js`. A negative value means that anchor's local north is counterclockwise from true north, so local east drifts a bit toward true north. -Most example geometry was copied from `buildings.gpkg` and preserved as JS-authored coordinates. Tree records also carry custom attributes like canopy diameter, height, and planted date for popup rendering. +Most example geometry was copied from `buildings.gpkg` and preserved as JS-authored coordinates. Tree records also carry custom attributes like canopy diameter, height, and lifecycle dates for popup rendering. -Feature relationships are exported with each GeoJSON feature as stable `id`, `parentId`, and `groupIds` properties. Each feature also exports universal `startDate` and `endDate` lifecycle fields for future time-slider filtering. The collection exposes `schemaVersion`, `lifecycleFields`, `featureTypes`, and `groups` at the top level. Style is resolved from the feature type registry at render time rather than stored on each exported feature. The current sample marks the porch and deck as part of the house and groups the Medora junipers under `Juniper Trees`. +Feature relationships are exported with each GeoJSON feature as stable `id`, `parentId`, and `groupIds` properties. Each feature also exports universal `plantedDate` and `removedDate` lifecycle fields for timeline filtering. The collection exposes `schemaVersion`, `lifecycleFields`, `featureTypes`, and `groups` at the top level. Style is resolved from the feature type registry at render time rather than stored on each exported feature. The current sample marks the porch and deck as part of the house and groups the Medora junipers under `Juniper Trees`. ## Project structure diff --git a/src/lib/feature-types.js b/src/lib/feature-types.js index 1148b75..516dd29 100644 --- a/src/lib/feature-types.js +++ b/src/lib/feature-types.js @@ -128,7 +128,6 @@ export const featureTypes = { species: "string", cultivar: "string", gardenOrgId: "string", - plantedDate: "date", note: "string", }, externalLinks: [ @@ -159,7 +158,6 @@ export const featureTypes = { cultivar: "string", bloomColor: "string", bloomSeason: "string", - plantedDate: "date", note: "string", }, style: { @@ -181,7 +179,6 @@ export const featureTypes = { cultivar: "string", bloomColor: "string", bloomSeason: "string", - plantedDate: "date", note: "string", }, style: { @@ -205,7 +202,6 @@ export const featureTypes = { cultivar: "string", canopyDiameterMeters: "number", heightMeters: "number", - plantedDate: "date", note: "string", }, style: (details) => ({ @@ -227,7 +223,6 @@ export const featureTypes = { cultivar: "string", canopyDiameterMeters: "number", heightMeters: "number", - plantedDate: "date", note: "string", }, style: (details) => ({ diff --git a/src/lib/yard-features.js b/src/lib/yard-features.js index b6d2858..17880fd 100644 --- a/src/lib/yard-features.js +++ b/src/lib/yard-features.js @@ -1,7 +1,7 @@ import { collectGeoJSON, offset, pointsFromLngLat } from "./geometry.js"; import { getFeatureType, listFeatureTypeSchemas } from "./feature-types.js"; -export const YARD_SCHEMA_VERSION = 1; +export const YARD_SCHEMA_VERSION = 2; class YardFeature { constructor(kind, geometryType, name, details = {}) { @@ -16,9 +16,9 @@ class YardFeature { this.geometryType = geometryType; this.name = name ?? featureType.label; this.id = buildFeatureId(kind, this.name); - const { startDate = null, endDate = null, ...restDetails } = details; - this.startDate = startDate; - this.endDate = endDate; + const { plantedDate = null, removedDate = null, ...restDetails } = details; + this.plantedDate = plantedDate; + this.removedDate = removedDate; this.details = { ...restDetails }; this.coordinates = []; this.parentId = null; @@ -48,19 +48,19 @@ class YardFeature { return this; } - activeFrom(startDate) { - this.startDate = startDate; + plantedOn(plantedDate) { + this.plantedDate = plantedDate; return this; } - activeUntil(endDate) { - this.endDate = endDate; + removedOn(removedDate) { + this.removedDate = removedDate; return this; } - withDateRange(startDate, endDate = null) { - this.startDate = startDate; - this.endDate = endDate; + withLifecycleDates(plantedDate, removedDate = null) { + this.plantedDate = plantedDate; + this.removedDate = removedDate; return this; } @@ -82,8 +82,8 @@ class YardFeature { kind: this.kind, label, name: this.name, - startDate: this.startDate, - endDate: this.endDate, + plantedDate: this.plantedDate, + removedDate: this.removedDate, parentId: this.parentId, groupIds: this.groupIds, details: this.details, @@ -334,8 +334,8 @@ export function collectYardData(features, groups = []) { const collection = collectGeoJSON(features); collection.schemaVersion = YARD_SCHEMA_VERSION; collection.lifecycleFields = { - startDate: "date", - endDate: "date", + plantedDate: "date", + removedDate: "date", }; collection.featureTypes = listFeatureTypeSchemas(); collection.groups = groups.map((group) => ({ diff --git a/src/main.js b/src/main.js index 9f32044..adc62d6 100644 --- a/src/main.js +++ b/src/main.js @@ -5,6 +5,23 @@ const TILE_MAX_NATIVE_ZOOM = 19; const MAP_MAX_ZOOM = 24; const MAP_ZOOM_STEP = 0.25; const WHEEL_PIXELS_PER_ZOOM_LEVEL = 240; +const TIMELINE_PRESETS = [ + { + id: "purchase", + label: "Axvigs purchase house", + date: "2021-02-01", + }, + { + id: "trees-2021", + label: "2021 trees planted", + date: "2021-09-22", + }, + { + id: "today", + label: "Today", + date: getTodayDateString(), + }, +]; const map = L.map("map", { zoomControl: false, @@ -27,76 +44,163 @@ L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", { attribution: '© OpenStreetMap', }).addTo(map); -L.geoJSON(yardGeoJSON, { - pointToLayer(feature, latlng) { - const style = getStyle(feature); - const options = { - color: style.color ?? "#333333", - fillColor: style.fillColor ?? style.color ?? "#333333", - fillOpacity: style.fillOpacity ?? 0.8, - weight: style.weight ?? 2, - }; - - if (style.iconUrl) { - const marker = L.marker(latlng, { - icon: buildIcon(style, latlng), - }); - - if (typeof style.iconDiameterMeters === "number") { - meterScaledIconMarkers.push({ marker, style, latlng }); - } - - return marker; - } - - if (typeof style.radiusMeters === "number") { - return L.circle(latlng, { - ...options, - radius: style.radiusMeters, - }); - } - - return L.circleMarker(latlng, { - ...options, - radius: style.radius ?? 6, - }); - }, - style(feature) { - if (feature.geometry.type === "Point") { - return getPathStyle(feature); - } - - return getStyle(feature); - }, - onEachFeature(feature, layer) { - layer.bindPopup(buildPopupContent(feature.properties)); - }, -}).addTo(map); +let selectedTimelineDate = TIMELINE_PRESETS.at(-1).date; +let yardLayer = null; +const timelineButtons = new Map(); +const legend = document.getElementById("legend"); +renderYardForDate(selectedTimelineDate); +addTimelineControl(); map.fitBounds(getGeoJSONBounds(yardGeoJSON).pad(0.2), { maxZoom: MAP_MAX_ZOOM }); updateMeterScaledIcons(); map.on("zoom", updateMeterScaledIcons); map.on("zoomend", updateMeterScaledIcons); -const legend = document.getElementById("legend"); -const legendEntries = summarizeTypes(yardGeoJSON.features); +function createYardLayer(collection) { + return L.geoJSON(collection, { + pointToLayer(feature, latlng) { + const style = getStyle(feature); + const options = { + color: style.color ?? "#333333", + fillColor: style.fillColor ?? style.color ?? "#333333", + fillOpacity: style.fillOpacity ?? 0.8, + weight: style.weight ?? 2, + }; -legend.replaceChildren( - ...legendEntries.map((entry) => { - const row = document.createElement("div"); - row.className = "legend-item"; + if (style.iconUrl) { + const marker = L.marker(latlng, { + icon: buildIcon(style, latlng), + }); - const swatch = document.createElement("span"); - swatch.className = "legend-swatch"; - swatch.style.background = entry.color; + if (typeof style.iconDiameterMeters === "number") { + meterScaledIconMarkers.push({ marker, style, latlng }); + } - const label = document.createElement("span"); - label.textContent = `${entry.label} (${entry.count})`; + return marker; + } - row.append(swatch, label); - return row; - }), -); + if (typeof style.radiusMeters === "number") { + return L.circle(latlng, { + ...options, + radius: style.radiusMeters, + }); + } + + return L.circleMarker(latlng, { + ...options, + radius: style.radius ?? 6, + }); + }, + style(feature) { + if (feature.geometry.type === "Point") { + return getPathStyle(feature); + } + + return getStyle(feature); + }, + onEachFeature(feature, layer) { + layer.bindPopup(buildPopupContent(feature.properties)); + }, + }); +} + +function renderYardForDate(date) { + const filteredYard = filterFeatureCollectionByDate(yardGeoJSON, date); + + if (yardLayer) { + yardLayer.remove(); + } + + meterScaledIconMarkers.length = 0; + yardLayer = createYardLayer(filteredYard).addTo(map); + renderLegend(filteredYard.features); + syncTimelineButtons(); + updateMeterScaledIcons(); +} + +function filterFeatureCollectionByDate(collection, date) { + return { + ...collection, + features: collection.features.filter((feature) => featureExistsOnDate(feature, date)), + }; +} + +function featureExistsOnDate(feature, date) { + const { plantedDate, removedDate } = feature.properties; + return (!plantedDate || plantedDate <= date) && (!removedDate || date <= removedDate); +} + +function addTimelineControl() { + const timelineControl = L.control({ position: "topright" }); + + timelineControl.onAdd = () => { + const container = L.DomUtil.create("div", "timeline-control"); + const title = document.createElement("div"); + title.className = "timeline-control-title"; + title.textContent = "Timeline"; + + const buttonGroup = document.createElement("div"); + buttonGroup.className = "timeline-buttons"; + buttonGroup.setAttribute("role", "group"); + buttonGroup.setAttribute("aria-label", "Choose map date"); + + for (const preset of TIMELINE_PRESETS) { + const button = document.createElement("button"); + button.type = "button"; + button.textContent = preset.label; + button.title = preset.date; + button.addEventListener("click", () => { + selectedTimelineDate = preset.date; + renderYardForDate(selectedTimelineDate); + }); + + timelineButtons.set(preset.id, button); + buttonGroup.append(button); + } + + container.append(title, buttonGroup); + L.DomEvent.disableClickPropagation(container); + L.DomEvent.disableScrollPropagation(container); + return container; + }; + + timelineControl.addTo(map); + syncTimelineButtons(); +} + +function syncTimelineButtons() { + for (const preset of TIMELINE_PRESETS) { + const button = timelineButtons.get(preset.id); + if (!button) { + continue; + } + + const isSelected = preset.date === selectedTimelineDate; + button.classList.toggle("is-selected", isSelected); + button.setAttribute("aria-pressed", String(isSelected)); + } +} + +function renderLegend(features) { + const legendEntries = summarizeTypes(features); + + legend.replaceChildren( + ...legendEntries.map((entry) => { + const row = document.createElement("div"); + row.className = "legend-item"; + + const swatch = document.createElement("span"); + swatch.className = "legend-swatch"; + swatch.style.background = entry.color; + + const label = document.createElement("span"); + label.textContent = `${entry.label} (${entry.count})`; + + row.append(swatch, label); + return row; + }), + ); +} function summarizeTypes(features) { const summary = new Map(); @@ -169,6 +273,13 @@ function getMetersPerPixel(lat) { return (156543.03392 * Math.cos((lat * Math.PI) / 180)) / 2 ** map.getZoom(); } +function getTodayDateString(date = new Date()) { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; +} + function getGeoJSONBounds(collection) { const bounds = L.latLngBounds(); @@ -192,7 +303,7 @@ function extendBounds(bounds, coordinates) { } function buildPopupContent(properties) { - const { kind, label, name, startDate, endDate, details, parentId, groupIds } = properties; + const { kind, label, name, plantedDate, removedDate, details, parentId, groupIds } = properties; const entries = Object.entries(details ?? {}).filter( ([key, value]) => value !== null && value !== undefined && shouldShowDetailField(key), ); @@ -200,12 +311,12 @@ function buildPopupContent(properties) { const relationshipEntries = []; const linkEntries = resolveFeatureLinks(kind, details); - if (startDate) { - lifecycleEntries.push(["startDate", startDate]); + if (plantedDate) { + lifecycleEntries.push(["plantedDate", plantedDate]); } - if (endDate) { - lifecycleEntries.push(["endDate", endDate]); + if (removedDate) { + lifecycleEntries.push(["removedDate", removedDate]); } if (parentId) { @@ -242,8 +353,7 @@ function formatDetailLabel(key) { canopyDiameterMeters: "Canopy diameter", heightMeters: "Height", plantedDate: "Planted", - startDate: "Start", - endDate: "End", + removedDate: "Removed", commonName: "Common name", genus: "Genus", cultivar: "Cultivar", diff --git a/styles.css b/styles.css index 89152a8..f2ca1bc 100644 --- a/styles.css +++ b/styles.css @@ -125,6 +125,56 @@ main { font-family: "Iowan Old Style", "Palatino Linotype", "URW Palladio L", serif; } +.timeline-control { + display: grid; + gap: 0.45rem; + min-width: 12rem; + padding: 0.7rem; + border: 1px solid var(--panel-border); + border-radius: 8px; + background: rgba(253, 250, 240, 0.94); + box-shadow: 0 12px 36px rgba(60, 44, 22, 0.16); + color: var(--ink); + font-family: "Iowan Old Style", "Palatino Linotype", "URW Palladio L", serif; +} + +.timeline-control-title { + font-size: 0.76rem; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--accent); +} + +.timeline-buttons { + display: grid; + gap: 0.35rem; +} + +.timeline-buttons button { + width: 100%; + border: 1px solid rgba(70, 107, 69, 0.28); + border-radius: 6px; + padding: 0.42rem 0.55rem; + background: #fffdf6; + color: var(--ink); + font: inherit; + text-align: left; + cursor: pointer; +} + +.timeline-buttons button:hover, +.timeline-buttons button:focus-visible { + border-color: rgba(70, 107, 69, 0.58); + outline: none; +} + +.timeline-buttons button.is-selected { + border-color: var(--accent); + background: var(--accent-soft); + font-weight: 700; +} + .error-overlay { position: fixed; right: 1rem; From d6656d71994347c524a545a8c61fe640ec4c2880 Mon Sep 17 00:00:00 2001 From: Aaron Axvig Date: Mon, 29 Jun 2026 14:47:00 -0500 Subject: [PATCH 07/23] More plants --- src/data/plants.js | 59 ++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 57 insertions(+), 2 deletions(-) diff --git a/src/data/plants.js b/src/data/plants.js index e7f8191..135bc0b 100644 --- a/src/data/plants.js +++ b/src/data/plants.js @@ -60,6 +60,14 @@ const daylilies = [ new Daylily("Predatory Flamingo", { gardenOrgId: "61201", }).add(geo.offset(lotCorner), geo.east(geo.ft(112)), geo.south(geo.ft(8))), + new Daylily("Unknown", { + }).add(geo.offset(lotCorner), geo.east(geo.ft(109)), geo.south(geo.ft(7))), + new Daylily("Unknown", { + }).add(geo.offset(lotCorner), geo.east(geo.ft(109)), geo.south(geo.ft(9))), + new Daylily("Unknown", { + }).add(geo.offset(lotCorner), geo.east(geo.ft(107)), geo.south(geo.ft(8))), + new Daylily("Unknown", { + }).add(geo.offset(lotCorner), geo.east(geo.ft(107)), geo.south(geo.ft(10))), new Daylily("Orange Daylily", { gardenOrgId: "48484", }).add(geo.offset(lotCorner), geo.east(geo.ft(142)), geo.south(geo.ft(24))), @@ -96,21 +104,27 @@ const daylilies = [ const flowers = [ new Flower("Purple Coneflower", { gardenOrgId: "71445", + plantedDate: "2024-05-01", }).add(geo.offset(lotCorner), geo.east(geo.ft(111.5)), geo.south(geo.ft(15))), new Flower("Purple Coneflower", { gardenOrgId: "71445", + plantedDate: "2024-05-01", }).add(geo.offset(lotCorner), geo.east(geo.ft(111.5)), geo.south(geo.ft(17))), new Flower("Purple Coneflower", { gardenOrgId: "71445", + plantedDate: "2024-05-01", }).add(geo.offset(lotCorner), geo.east(geo.ft(112.5)), geo.south(geo.ft(16))), new Flower("Purple Coneflower", { gardenOrgId: "71445", + plantedDate: "2024-05-01", }).add(geo.offset(lotCorner), geo.east(geo.ft(112.5)), geo.south(geo.ft(14))), new Flower("Tiny Tortuga Turtlehead", { gardenOrgId: "697986", + plantedDate: "2021-09-21", }).add(geo.offset(lotCorner), geo.east(geo.ft(3)), geo.south(geo.ft(8))), new Flower("Tiny Tortuga Turtlehead", { gardenOrgId: "697986", + plantedDate: "2021-09-21", }).add(geo.offset(lotCorner), geo.east(geo.ft(3)), geo.south(geo.ft(17))), new Flower("Peony", { gardenOrgId: "697986", @@ -120,52 +134,82 @@ const flowers = [ }).add(geo.offset(lotCorner), geo.east(geo.ft(75)), geo.south(geo.ft(2))), ]; +const cutDownTrees = [ + new Tree("Maybe elm", { + canopyDiameterMeters: 15, + heightMeters: 15, + removedDate: "2021-05-05", + }).add(geo.offset(lotCorner), geo.east(geo.ft(65)), geo.south(geo.ft(-2))), + new Tree("Big pine tree", { + canopyDiameterMeters: 8, + heightMeters: 15, + removedDate: "2021-05-06", + }).add(geo.offset(lotCorner), geo.east(geo.ft(98)), geo.south(geo.ft(-15))), + new Tree("Walnut by house", { + canopyDiameterMeters: 6, + heightMeters: 7, + removedDate: "2021-05-01", + }).add(geo.offset(lotCorner), geo.east(geo.ft(32)), geo.south(geo.ft(40))), + new Tree("Walnut by shed", { + canopyDiameterMeters: 7, + heightMeters: 7, + removedDate: "2021-05-01", + }).add(geo.offset(lotCorner), geo.east(geo.ft(100)), geo.south(geo.ft(40))), +]; + const trees = [ new Tree("Northern Empress Japanese Elm", { species: "Northern Empress Japanese Elm", canopyDiameterMeters: 6, heightMeters: 7, + plantedDate: "2021-09-21", }).add(geo.offset(lotCorner), geo.east(geo.ft(9)), geo.south(geo.ft(16))), new Tree("Hot Wings Maple", { species: "Hot Wings Maple", canopyDiameterMeters: 5, heightMeters: 5, gardenOrgId: "536120", + plantedDate: "2021-09-21", }).add(geo.offset(lotCorner), geo.east(geo.ft(60)), geo.south(geo.ft(40))), new Tree("Prairie Expedition American Elm", { species: "Prairie Expedition American Elm", canopyDiameterMeters: 5, heightMeters: 5, gardenOrgId: "736267", + plantedDate: "2021-09-21", }).add(geo.offset(lotCorner), geo.east(geo.ft(95)), geo.south(geo.ft(15))), new Tree("Dwarf Korean Lilac", { species: "Dwarf Korean Lilac", canopyDiameterMeters: 1.75, heightMeters: 2.5, gardenOrgId: "79137", + plantedDate: "2021-09-21", }).add(geo.offset(lotCorner), geo.east(geo.ft(3)), geo.south(geo.ft(3))), new Tree("Dwarf Korean Lilac", { species: "Dwarf Korean Lilac", canopyDiameterMeters: 1.75, heightMeters: 2.5, gardenOrgId: "79137", + plantedDate: "2021-09-21", }).add(geo.offset(lotCorner), geo.east(geo.ft(3)), geo.south(geo.ft(20))), new Tree("Dwarf Korean Lilac", { species: "Dwarf Korean Lilac", canopyDiameterMeters: 1.75, heightMeters: 2.5, gardenOrgId: "79137", + plantedDate: "2021-09-21", }).add(geo.offset(lotCorner), geo.east(geo.ft(3)), geo.south(geo.ft(37))), new Tree("Boulevard Linden", { species: "Boulevard Linden", canopyDiameterMeters: 3, heightMeters: 6, - plantedDate: "2021-06-08", + plantedDate: "2021-09-21", }).add(geo.lngLat(-100.8990003556445, 46.826776648309774)), new Tree("Boulevard Linden 2", { species: "Boulevard Linden", canopyDiameterMeters: 3, heightMeters: 6, + plantedDate: "2021-09-21", }).add(geo.lngLat(-100.89898269567485, 46.826709328342595)), new Tree("Evergreen", { species: "Evergreen", @@ -176,26 +220,31 @@ const trees = [ species: "Medora Juniper", canopyDiameterMeters: 1.5, heightMeters: 2.5, + plantedDate: "2021-09-21", }).inGroup(juniperTreesGroup).add(geo.lngLat(-100.89880933964611, 46.826705259550664)), new Tree("Medora Juniper 2", { species: "Medora Juniper", canopyDiameterMeters: 1.5, heightMeters: 2.5, + plantedDate: "2021-09-21", }).inGroup(juniperTreesGroup).add(geo.lngLat(-100.89877004677517, 46.82670396593881)), new Tree("Medora Juniper 3", { species: "Medora Juniper", canopyDiameterMeters: 1.5, heightMeters: 2.5, + plantedDate: "2021-09-21", }).inGroup(juniperTreesGroup).add(geo.lngLat(-100.898799106227, 46.82669830398086)), new Tree("Medora Juniper 4", { species: "Medora Juniper", canopyDiameterMeters: 1.5, heightMeters: 2.5, + plantedDate: "2021-09-21", }).inGroup(juniperTreesGroup).add(geo.lngLat(-100.89878871410515, 46.82669251034885)), new Tree("Medora Juniper 5", { species: "Medora Juniper", canopyDiameterMeters: 1.5, heightMeters: 2.5, + plantedDate: "2021-09-21", }).inGroup(juniperTreesGroup).add(geo.lngLat(-100.8987812086838, 46.82671120797719)), new Tree("Black Currant", { species: "Black Currant", @@ -203,7 +252,13 @@ const trees = [ heightMeters: 2, gardenOrgId: "87767", }).add(geo.offset(lotCorner), geo.east(geo.ft(107)), geo.south(geo.ft(3))), + new Tree("Elderberry", { + species: "Elderberry", + canopyDiameterMeters: 3, + heightMeters: 3, + gardenOrgId: "78882", + }).add(geo.offset(lotCorner), geo.east(geo.ft(142)), geo.south(geo.ft(52))), ]; -export const plantFeatures = [...flowerBeds, ...daylilies, ...flowers, ...trees]; +export const plantFeatures = [...flowerBeds, ...daylilies, ...flowers, ...trees, ...cutDownTrees]; export const plantGroups = [juniperTreesGroup]; From ac96cd861d2f9f45d3ced63f38c7a586f711a2cf Mon Sep 17 00:00:00 2001 From: Aaron Axvig Date: Sun, 5 Jul 2026 21:42:23 -0500 Subject: [PATCH 08/23] Add some plants --- src/data/plants.js | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/src/data/plants.js b/src/data/plants.js index 135bc0b..c9221d5 100644 --- a/src/data/plants.js +++ b/src/data/plants.js @@ -134,6 +134,12 @@ const flowers = [ }).add(geo.offset(lotCorner), geo.east(geo.ft(75)), geo.south(geo.ft(2))), ]; +const houseplants = [ + new Flower("Variegata", { + gardenOrgId: "126796", + }).add(geo.offset(lotCorner), geo.east(geo.ft(60)), geo.south(geo.ft(28))), +] + const cutDownTrees = [ new Tree("Maybe elm", { canopyDiameterMeters: 15, @@ -216,6 +222,9 @@ const trees = [ canopyDiameterMeters: 8, heightMeters: 15, }).add(geo.lngLat(-100.89888268258135, 46.826680107011924)), +]; + +const bushes = [ new Tree("Medora Juniper 1", { species: "Medora Juniper", canopyDiameterMeters: 1.5, @@ -258,7 +267,22 @@ const trees = [ heightMeters: 3, gardenOrgId: "78882", }).add(geo.offset(lotCorner), geo.east(geo.ft(142)), geo.south(geo.ft(52))), + new Tree("Spirea", { + species: "Spirea", + canopyDiameterMeters: 1.5, + heightMeters: 1, + }).add(geo.offset(lotCorner), geo.east(geo.ft(70)), geo.south(geo.ft(31))), + new Tree("Spirea", { + species: "Spirea", + canopyDiameterMeters: 1.5, + heightMeters: 1, + }).add(geo.offset(lotCorner), geo.east(geo.ft(74)), geo.south(geo.ft(31))), + new Tree("Spirea", { + species: "Spirea", + canopyDiameterMeters: 1.5, + heightMeters: 1, + }).add(geo.offset(lotCorner), geo.east(geo.ft(78)), geo.south(geo.ft(31))), ]; -export const plantFeatures = [...flowerBeds, ...daylilies, ...flowers, ...trees, ...cutDownTrees]; +export const plantFeatures = [...flowerBeds, ...daylilies, ...flowers, ...houseplants, ...trees, ...bushes, ...cutDownTrees]; export const plantGroups = [juniperTreesGroup]; From f5cfb95b8c1b67637a6c5106313b2ee650d08c31 Mon Sep 17 00:00:00 2001 From: Aaron Axvig Date: Sun, 5 Jul 2026 21:47:03 -0500 Subject: [PATCH 09/23] Rebranding --- AGENTS.md | 2 +- README.md | 6 +++--- index.html | 23 ++++++----------------- package.json | 4 ++-- 4 files changed, 12 insertions(+), 23 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c645ce0..a154484 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,4 +1,4 @@ -# Yard Map Agent Notes +# A Plot of Plants Agent Notes This file is a fast handoff for future coding agents working in this repo. diff --git a/README.md b/README.md index 0624186..edc1a4f 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# Yard Map +# A Plot of Plants -Static yard mapping site built with Leaflet and plain browser modules. +Static garden and yard mapping site built with Leaflet and plain browser modules. ## Status @@ -14,7 +14,7 @@ The project currently has: - Feature relationship metadata (`id`, `parentId`, `groupIds`) and a top-level group catalog for future visibility controls. - Example yard data mostly copied from `buildings.gpkg` and then preserved as JS modules. -The next likely product direction is map-layer visibility controls driven by the existing feature groups and parent-child relationships. +The next likely product direction is layer visibility controls driven by the existing feature groups and parent-child relationships. ## Authoring model diff --git a/index.html b/index.html index 9d61ed1..25ae2c2 100644 --- a/index.html +++ b/index.html @@ -3,32 +3,21 @@ - Yard Map + A Plot of Plants
diff --git a/package.json b/package.json index 6f987e6..2b3d64b 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "yardmap", + "name": "a-plot-of-plants", "private": true, "type": "module" -} \ No newline at end of file +} From 9b5545c2b8bc24932aab47364b7e611f5bdad202 Mon Sep 17 00:00:00 2001 From: Aaron Axvig Date: Sun, 5 Jul 2026 21:55:15 -0500 Subject: [PATCH 10/23] More mobile-friendly --- styles.css | 111 ++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 110 insertions(+), 1 deletion(-) diff --git a/styles.css b/styles.css index f2ca1bc..0bdf2fd 100644 --- a/styles.css +++ b/styles.css @@ -27,6 +27,7 @@ body { body { min-height: 100vh; + min-height: 100svh; } .app-shell { @@ -107,11 +108,14 @@ code { } main { + display: flex; min-width: 0; } #map { + flex: 1; min-height: calc(100vh - 2rem); + min-height: calc(100svh - 2rem); border-radius: 28px; overflow: hidden; box-shadow: var(--shadow); @@ -233,11 +237,116 @@ main { } @media (max-width: 900px) { + body { + min-height: auto; + } + .app-shell { grid-template-columns: 1fr; + gap: 0.75rem; + min-height: auto; + padding: 0.75rem; + padding: + max(0.75rem, env(safe-area-inset-top)) + max(0.75rem, env(safe-area-inset-right)) + max(0.75rem, env(safe-area-inset-bottom)) + max(0.75rem, env(safe-area-inset-left)); + } + + main { + order: -1; + min-height: min(72svh, 46rem); + } + + .panel { + padding: 1rem; + border-radius: 12px; + box-shadow: 0 12px 36px rgba(60, 44, 22, 0.14); + } + + h1 { + margin-bottom: 0.65rem; + font-size: clamp(2rem, 12vw, 3rem); + } + + .intro { + margin-bottom: 0; + } + + .legend { + grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr)); + gap: 0.5rem 0.75rem; + margin: 1rem 0 0; + } + + .legend-item { + gap: 0.5rem; + min-width: 0; + font-size: 0.9rem; + } + + .legend-item span:last-child { + min-width: 0; + overflow-wrap: anywhere; } #map { - min-height: 65vh; + min-height: min(72svh, 46rem); + border-radius: 12px; + } + + .timeline-control { + width: min(18rem, calc(100vw - 2rem)); + max-width: calc(100vw - 2rem); + } + + .timeline-buttons { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .timeline-buttons button { + min-height: 2.65rem; + padding: 0.38rem 0.45rem; + text-align: center; + overflow-wrap: anywhere; + } + + .leaflet-control-container .leaflet-top { + top: 0.5rem; + } + + .leaflet-control-container .leaflet-right { + right: 0.5rem; + } + + .leaflet-control-container .leaflet-bottom { + bottom: 0.5rem; + } + + .leaflet-popup-content { + max-width: min(16rem, calc(100vw - 5rem)); + } +} + +@media (max-width: 520px) { + .app-shell { + padding: 0; + } + + main, + #map { + min-height: 68svh; + } + + #map { + border-radius: 0; + } + + .panel { + margin: 0 0.75rem 0.75rem; + } + + .timeline-control { + padding: 0.55rem; } } From 21a5bf185da4b8eab8395d146af5355ae89252a2 Mon Sep 17 00:00:00 2001 From: Aaron Axvig Date: Sun, 5 Jul 2026 22:12:51 -0500 Subject: [PATCH 11/23] Add daylilies --- src/data/plants.js | 43 ++++++++++++++++++++++++++++++++++++------- 1 file changed, 36 insertions(+), 7 deletions(-) diff --git a/src/data/plants.js b/src/data/plants.js index c9221d5..34e86b9 100644 --- a/src/data/plants.js +++ b/src/data/plants.js @@ -48,18 +48,24 @@ const flowerBeds = [ ]; const daylilies = [ - new Daylily("Not sure", { - gardenOrgId: "4765", - }).add(geo.offset(lotCorner), geo.east(geo.ft(112)), geo.south(geo.ft(2))), + new Daylily("Trahlyta", { + gardenOrgId: "18613", + }).add(geo.offset(lotCorner), geo.east(geo.ft(112)), geo.south(geo.ft(1))), new Daylily("Wayside King Royale", { gardenOrgId: "5090", - }).add(geo.offset(lotCorner), geo.east(geo.ft(112)), geo.south(geo.ft(4))), + }).add(geo.offset(lotCorner), geo.east(geo.ft(112)), geo.south(geo.ft(3))), + new Daylily("Night Stalker", { + gardenOrgId: "58861", + }).add(geo.offset(lotCorner), geo.east(geo.ft(112)), geo.south(geo.ft(5))), + new Daylily("Lemon Fringed Lemons", { + gardenOrgId: "185536", + }).add(geo.offset(lotCorner), geo.east(geo.ft(112)), geo.south(geo.ft(7))), new Daylily("Joan Derifield", { gardenOrgId: "4765", - }).add(geo.offset(lotCorner), geo.east(geo.ft(112)), geo.south(geo.ft(6))), + }).add(geo.offset(lotCorner), geo.east(geo.ft(112)), geo.south(geo.ft(9))), new Daylily("Predatory Flamingo", { gardenOrgId: "61201", - }).add(geo.offset(lotCorner), geo.east(geo.ft(112)), geo.south(geo.ft(8))), + }).add(geo.offset(lotCorner), geo.east(geo.ft(112)), geo.south(geo.ft(11))), new Daylily("Unknown", { }).add(geo.offset(lotCorner), geo.east(geo.ft(109)), geo.south(geo.ft(7))), new Daylily("Unknown", { @@ -92,13 +98,36 @@ const daylilies = [ new Daylily("Orange Daylily", { gardenOrgId: "48484", }).add(geo.offset(lotCorner), geo.east(geo.ft(142)), geo.south(geo.ft(38))), + new Daylily("Extra daylily", { + gardenOrgId: "", + }).add(geo.offset(lotCorner), geo.east(geo.ft(1)), geo.south(geo.ft(4))), + new Daylily("Extra daylily", { + gardenOrgId: "", + }).add(geo.offset(lotCorner), geo.east(geo.ft(1)), geo.south(geo.ft(9))), + new Daylily("Extra daylily", { + gardenOrgId: "", + }).add(geo.offset(lotCorner), geo.east(geo.ft(1)), geo.south(geo.ft(17))), + new Daylily("Can't read", { + gardenOrgId: "", + }).add(geo.offset(lotCorner), geo.east(geo.ft(1)), geo.south(geo.ft(25))), + new Daylily("J.T. Davis", { + gardenOrgId: "29649", + }).add(geo.offset(lotCorner), geo.east(geo.ft(1)), geo.south(geo.ft(31))), + new Daylily("Tea for Teagan", { + gardenOrgId: "787574", + }).add(geo.offset(lotCorner), geo.east(geo.ft(1)), geo.south(geo.ft(34))), + new Daylily("Burning So Brightly", { + gardenOrgId: "610042", + }).add(geo.offset(lotCorner), geo.east(geo.ft(2)), geo.south(geo.ft(32.5))), new Daylily("You Had Me at Hello", { gardenOrgId: "235117", }).add(geo.offset(lotCorner), geo.east(geo.ft(28)), geo.south(geo.ft(45))), new Daylily("Mapping North Dakota", { gardenOrgId: "56003", }).add(geo.offset(lotCorner), geo.east(geo.ft(28)), geo.south(geo.ft(47))), - new Daylily("Daylily").add(geo.offset(lotCorner), geo.east(geo.ft(31)), geo.south(geo.ft(47))), + new Daylily("Monster", { + gardenOrgId: "15661", + }).add(geo.offset(lotCorner), geo.east(geo.ft(31)), geo.south(geo.ft(47))), ]; const flowers = [ From 5950a80a17e7ff9c23cf9159bf12b495e6d0dec0 Mon Sep 17 00:00:00 2001 From: Aaron Axvig Date: Sun, 5 Jul 2026 22:21:58 -0500 Subject: [PATCH 12/23] More daylilies IDed --- src/data/plants.js | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/src/data/plants.js b/src/data/plants.js index 34e86b9..1de26f7 100644 --- a/src/data/plants.js +++ b/src/data/plants.js @@ -56,23 +56,35 @@ const daylilies = [ }).add(geo.offset(lotCorner), geo.east(geo.ft(112)), geo.south(geo.ft(3))), new Daylily("Night Stalker", { gardenOrgId: "58861", + plantedDate: "2023-05-01", }).add(geo.offset(lotCorner), geo.east(geo.ft(112)), geo.south(geo.ft(5))), new Daylily("Lemon Fringed Lemons", { gardenOrgId: "185536", + plantedDate: "2023-05-01", }).add(geo.offset(lotCorner), geo.east(geo.ft(112)), geo.south(geo.ft(7))), new Daylily("Joan Derifield", { gardenOrgId: "4765", + plantedDate: "2023-05-01", }).add(geo.offset(lotCorner), geo.east(geo.ft(112)), geo.south(geo.ft(9))), new Daylily("Predatory Flamingo", { gardenOrgId: "61201", + plantedDate: "2023-05-01", }).add(geo.offset(lotCorner), geo.east(geo.ft(112)), geo.south(geo.ft(11))), - new Daylily("Unknown", { + new Daylily("Double Me", { + gardenOrgId: "7872", + plantedDate: "2022-05-01", }).add(geo.offset(lotCorner), geo.east(geo.ft(109)), geo.south(geo.ft(7))), - new Daylily("Unknown", { + new Daylily("Lipstick Spitfire", { + gardenOrgId: "708897", + plantedDate: "2022-05-01", }).add(geo.offset(lotCorner), geo.east(geo.ft(109)), geo.south(geo.ft(9))), - new Daylily("Unknown", { + new Daylily("Jerry Hyatt", { + gardenOrgId: "32921", + plantedDate: "2022-05-01", }).add(geo.offset(lotCorner), geo.east(geo.ft(107)), geo.south(geo.ft(8))), - new Daylily("Unknown", { + new Daylily("Take Me Home", { + gardenOrgId: "791256", + plantedDate: "2022-05-01", }).add(geo.offset(lotCorner), geo.east(geo.ft(107)), geo.south(geo.ft(10))), new Daylily("Orange Daylily", { gardenOrgId: "48484", @@ -112,12 +124,15 @@ const daylilies = [ }).add(geo.offset(lotCorner), geo.east(geo.ft(1)), geo.south(geo.ft(25))), new Daylily("J.T. Davis", { gardenOrgId: "29649", + plantedDate: "2023-05-01", }).add(geo.offset(lotCorner), geo.east(geo.ft(1)), geo.south(geo.ft(31))), new Daylily("Tea for Teagan", { gardenOrgId: "787574", + plantedDate: "2023-05-01", }).add(geo.offset(lotCorner), geo.east(geo.ft(1)), geo.south(geo.ft(34))), new Daylily("Burning So Brightly", { gardenOrgId: "610042", + plantedDate: "2023-05-01", }).add(geo.offset(lotCorner), geo.east(geo.ft(2)), geo.south(geo.ft(32.5))), new Daylily("You Had Me at Hello", { gardenOrgId: "235117", From 07c6a9e390ee7ef864cad9f2820dd68526d8a248 Mon Sep 17 00:00:00 2001 From: Aaron Axvig Date: Mon, 6 Jul 2026 08:22:52 -0500 Subject: [PATCH 13/23] Add prices --- src/data/plants.js | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/data/plants.js b/src/data/plants.js index 1de26f7..622c175 100644 --- a/src/data/plants.js +++ b/src/data/plants.js @@ -57,18 +57,22 @@ const daylilies = [ new Daylily("Night Stalker", { gardenOrgId: "58861", plantedDate: "2023-05-01", + pricePaid: 16, }).add(geo.offset(lotCorner), geo.east(geo.ft(112)), geo.south(geo.ft(5))), new Daylily("Lemon Fringed Lemons", { gardenOrgId: "185536", plantedDate: "2023-05-01", + pricePaid: 15, }).add(geo.offset(lotCorner), geo.east(geo.ft(112)), geo.south(geo.ft(7))), new Daylily("Joan Derifield", { gardenOrgId: "4765", plantedDate: "2023-05-01", + pricePaid: 15, }).add(geo.offset(lotCorner), geo.east(geo.ft(112)), geo.south(geo.ft(9))), new Daylily("Predatory Flamingo", { gardenOrgId: "61201", plantedDate: "2023-05-01", + pricePaid: 6, }).add(geo.offset(lotCorner), geo.east(geo.ft(112)), geo.south(geo.ft(11))), new Daylily("Double Me", { gardenOrgId: "7872", @@ -125,14 +129,17 @@ const daylilies = [ new Daylily("J.T. Davis", { gardenOrgId: "29649", plantedDate: "2023-05-01", + pricePaid: 21, }).add(geo.offset(lotCorner), geo.east(geo.ft(1)), geo.south(geo.ft(31))), new Daylily("Tea for Teagan", { gardenOrgId: "787574", plantedDate: "2023-05-01", + pricePaid: 15, }).add(geo.offset(lotCorner), geo.east(geo.ft(1)), geo.south(geo.ft(34))), new Daylily("Burning So Brightly", { gardenOrgId: "610042", plantedDate: "2023-05-01", + pricePaid: 17, }).add(geo.offset(lotCorner), geo.east(geo.ft(2)), geo.south(geo.ft(32.5))), new Daylily("You Had Me at Hello", { gardenOrgId: "235117", @@ -213,6 +220,7 @@ const trees = [ canopyDiameterMeters: 6, heightMeters: 7, plantedDate: "2021-09-21", + pricePaid: 312, }).add(geo.offset(lotCorner), geo.east(geo.ft(9)), geo.south(geo.ft(16))), new Tree("Hot Wings Maple", { species: "Hot Wings Maple", @@ -220,6 +228,7 @@ const trees = [ heightMeters: 5, gardenOrgId: "536120", plantedDate: "2021-09-21", + pricePaid: 186, }).add(geo.offset(lotCorner), geo.east(geo.ft(60)), geo.south(geo.ft(40))), new Tree("Prairie Expedition American Elm", { species: "Prairie Expedition American Elm", @@ -227,6 +236,7 @@ const trees = [ heightMeters: 5, gardenOrgId: "736267", plantedDate: "2021-09-21", + pricePaid: 280, }).add(geo.offset(lotCorner), geo.east(geo.ft(95)), geo.south(geo.ft(15))), new Tree("Dwarf Korean Lilac", { species: "Dwarf Korean Lilac", @@ -234,6 +244,7 @@ const trees = [ heightMeters: 2.5, gardenOrgId: "79137", plantedDate: "2021-09-21", + pricePaid: 218, }).add(geo.offset(lotCorner), geo.east(geo.ft(3)), geo.south(geo.ft(3))), new Tree("Dwarf Korean Lilac", { species: "Dwarf Korean Lilac", @@ -241,6 +252,7 @@ const trees = [ heightMeters: 2.5, gardenOrgId: "79137", plantedDate: "2021-09-21", + pricePaid: 218, }).add(geo.offset(lotCorner), geo.east(geo.ft(3)), geo.south(geo.ft(20))), new Tree("Dwarf Korean Lilac", { species: "Dwarf Korean Lilac", @@ -248,18 +260,21 @@ const trees = [ heightMeters: 2.5, gardenOrgId: "79137", plantedDate: "2021-09-21", + pricePaid: 218, }).add(geo.offset(lotCorner), geo.east(geo.ft(3)), geo.south(geo.ft(37))), new Tree("Boulevard Linden", { species: "Boulevard Linden", canopyDiameterMeters: 3, heightMeters: 6, plantedDate: "2021-09-21", + pricePaid: 327, }).add(geo.lngLat(-100.8990003556445, 46.826776648309774)), new Tree("Boulevard Linden 2", { species: "Boulevard Linden", canopyDiameterMeters: 3, heightMeters: 6, plantedDate: "2021-09-21", + pricePaid: 327, }).add(geo.lngLat(-100.89898269567485, 46.826709328342595)), new Tree("Evergreen", { species: "Evergreen", @@ -274,30 +289,35 @@ const bushes = [ canopyDiameterMeters: 1.5, heightMeters: 2.5, plantedDate: "2021-09-21", + pricePaid: 96.50, }).inGroup(juniperTreesGroup).add(geo.lngLat(-100.89880933964611, 46.826705259550664)), new Tree("Medora Juniper 2", { species: "Medora Juniper", canopyDiameterMeters: 1.5, heightMeters: 2.5, plantedDate: "2021-09-21", + pricePaid: 96.50, }).inGroup(juniperTreesGroup).add(geo.lngLat(-100.89877004677517, 46.82670396593881)), new Tree("Medora Juniper 3", { species: "Medora Juniper", canopyDiameterMeters: 1.5, heightMeters: 2.5, plantedDate: "2021-09-21", + pricePaid: 96.50, }).inGroup(juniperTreesGroup).add(geo.lngLat(-100.898799106227, 46.82669830398086)), new Tree("Medora Juniper 4", { species: "Medora Juniper", canopyDiameterMeters: 1.5, heightMeters: 2.5, plantedDate: "2021-09-21", + pricePaid: 96.50, }).inGroup(juniperTreesGroup).add(geo.lngLat(-100.89878871410515, 46.82669251034885)), new Tree("Medora Juniper 5", { species: "Medora Juniper", canopyDiameterMeters: 1.5, heightMeters: 2.5, plantedDate: "2021-09-21", + pricePaid: 96.50, }).inGroup(juniperTreesGroup).add(geo.lngLat(-100.8987812086838, 46.82671120797719)), new Tree("Black Currant", { species: "Black Currant", From da6371b53f6e4904e2c3d9d3b16042feb28074e5 Mon Sep 17 00:00:00 2001 From: aaronaxvig Date: Tue, 7 Jul 2026 21:43:04 -0500 Subject: [PATCH 14/23] Improvements to tree size measurements --- src/data/plants.js | 91 ++++++++++++++++++---------------------- src/lib/feature-types.js | 23 +++++++--- src/lib/yard-features.js | 68 +++++++++++++++++++++++++++++- src/main.js | 51 +++++++++++++++++++--- 4 files changed, 169 insertions(+), 64 deletions(-) diff --git a/src/data/plants.js b/src/data/plants.js index 622c175..a894afa 100644 --- a/src/data/plants.js +++ b/src/data/plants.js @@ -116,7 +116,13 @@ const daylilies = [ }).add(geo.offset(lotCorner), geo.east(geo.ft(142)), geo.south(geo.ft(38))), new Daylily("Extra daylily", { gardenOrgId: "", - }).add(geo.offset(lotCorner), geo.east(geo.ft(1)), geo.south(geo.ft(4))), + }).add(geo.offset(lotCorner), geo.east(geo.ft(0.8)), geo.south(geo.ft(3.2))), + new Daylily("Extra daylily", { + gardenOrgId: "", + }).add(geo.offset(lotCorner), geo.east(geo.ft(0.8)), geo.south(geo.ft(3.9))), + new Daylily("Extra daylily", { + gardenOrgId: "", + }).add(geo.offset(lotCorner), geo.east(geo.ft(1.3)), geo.south(geo.ft(3.6))), new Daylily("Extra daylily", { gardenOrgId: "", }).add(geo.offset(lotCorner), geo.east(geo.ft(1)), geo.south(geo.ft(9))), @@ -172,7 +178,7 @@ const flowers = [ new Flower("Tiny Tortuga Turtlehead", { gardenOrgId: "697986", plantedDate: "2021-09-21", - }).add(geo.offset(lotCorner), geo.east(geo.ft(3)), geo.south(geo.ft(8))), + }).add(geo.offset(lotCorner), geo.east(geo.ft(2.2)), geo.south(geo.ft(8.2))), new Flower("Tiny Tortuga Turtlehead", { gardenOrgId: "697986", plantedDate: "2021-09-21", @@ -193,23 +199,19 @@ const houseplants = [ const cutDownTrees = [ new Tree("Maybe elm", { - canopyDiameterMeters: 15, - heightMeters: 15, + measurements: [{ canopyDiameter: geo.ft(49.21), height: geo.ft(49.21) }], removedDate: "2021-05-05", }).add(geo.offset(lotCorner), geo.east(geo.ft(65)), geo.south(geo.ft(-2))), new Tree("Big pine tree", { - canopyDiameterMeters: 8, - heightMeters: 15, + measurements: [{ canopyDiameter: geo.ft(26.25), height: geo.ft(49.21) }], removedDate: "2021-05-06", }).add(geo.offset(lotCorner), geo.east(geo.ft(98)), geo.south(geo.ft(-15))), new Tree("Walnut by house", { - canopyDiameterMeters: 6, - heightMeters: 7, + measurements: [{ canopyDiameter: geo.ft(19.69), height: geo.ft(22.97) }], removedDate: "2021-05-01", }).add(geo.offset(lotCorner), geo.east(geo.ft(32)), geo.south(geo.ft(40))), new Tree("Walnut by shed", { - canopyDiameterMeters: 7, - heightMeters: 7, + measurements: [{ canopyDiameter: geo.ft(22.97), height: geo.ft(22.97) }], removedDate: "2021-05-01", }).add(geo.offset(lotCorner), geo.east(geo.ft(100)), geo.south(geo.ft(40))), ]; @@ -217,134 +219,121 @@ const cutDownTrees = [ const trees = [ new Tree("Northern Empress Japanese Elm", { species: "Northern Empress Japanese Elm", - canopyDiameterMeters: 6, - heightMeters: 7, + measurements: [{ canopyDiameter: geo.ft(19.69), height: geo.ft(22.97) }], plantedDate: "2021-09-21", pricePaid: 312, }).add(geo.offset(lotCorner), geo.east(geo.ft(9)), geo.south(geo.ft(16))), new Tree("Hot Wings Maple", { species: "Hot Wings Maple", - canopyDiameterMeters: 5, - heightMeters: 5, + measurements: [{ canopyDiameter: geo.ft(16.4), height: geo.ft(16.4) }], gardenOrgId: "536120", plantedDate: "2021-09-21", pricePaid: 186, }).add(geo.offset(lotCorner), geo.east(geo.ft(60)), geo.south(geo.ft(40))), new Tree("Prairie Expedition American Elm", { species: "Prairie Expedition American Elm", - canopyDiameterMeters: 5, - heightMeters: 5, + measurements: [{ canopyDiameter: geo.ft(16.4), height: geo.ft(16.4) }], gardenOrgId: "736267", plantedDate: "2021-09-21", pricePaid: 280, }).add(geo.offset(lotCorner), geo.east(geo.ft(95)), geo.south(geo.ft(15))), + new Tree("Dwarf Korean Lilac", { species: "Dwarf Korean Lilac", - canopyDiameterMeters: 1.75, - heightMeters: 2.5, + measurements: [ + { measuredDate: "2026-07-08", canopyDiameter: geo.ft(5.5), height: geo.ft(6.7) }, + ], gardenOrgId: "79137", plantedDate: "2021-09-21", pricePaid: 218, - }).add(geo.offset(lotCorner), geo.east(geo.ft(3)), geo.south(geo.ft(3))), + }).add(geo.offset(lotCorner), geo.east(geo.ft(3.5)), geo.south(geo.ft(4.8))), + new Tree("Dwarf Korean Lilac", { species: "Dwarf Korean Lilac", - canopyDiameterMeters: 1.75, - heightMeters: 2.5, + measurements: [{ canopyDiameter: geo.ft(5.74), height: geo.ft(8.2) }], gardenOrgId: "79137", plantedDate: "2021-09-21", pricePaid: 218, - }).add(geo.offset(lotCorner), geo.east(geo.ft(3)), geo.south(geo.ft(20))), + }).add(geo.offset(lotCorner), geo.east(geo.ft(3.7)), geo.south(geo.ft(21.4))), + new Tree("Dwarf Korean Lilac", { species: "Dwarf Korean Lilac", - canopyDiameterMeters: 1.75, - heightMeters: 2.5, + measurements: [{ canopyDiameter: geo.ft(5.74), height: geo.ft(8.2) }], gardenOrgId: "79137", plantedDate: "2021-09-21", pricePaid: 218, - }).add(geo.offset(lotCorner), geo.east(geo.ft(3)), geo.south(geo.ft(37))), + }).add(geo.offset(lotCorner), geo.east(geo.ft(3.8)), geo.south(geo.ft(33.1))), + new Tree("Boulevard Linden", { species: "Boulevard Linden", - canopyDiameterMeters: 3, - heightMeters: 6, + measurements: [{ canopyDiameter: geo.ft(9.84), height: geo.ft(19.69) }], plantedDate: "2021-09-21", pricePaid: 327, }).add(geo.lngLat(-100.8990003556445, 46.826776648309774)), new Tree("Boulevard Linden 2", { species: "Boulevard Linden", - canopyDiameterMeters: 3, - heightMeters: 6, + measurements: [{ canopyDiameter: geo.ft(9.84), height: geo.ft(19.69) }], plantedDate: "2021-09-21", pricePaid: 327, }).add(geo.lngLat(-100.89898269567485, 46.826709328342595)), new Tree("Evergreen", { species: "Evergreen", - canopyDiameterMeters: 8, - heightMeters: 15, + measurements: [{ canopyDiameter: geo.ft(26.25), height: geo.ft(49.21) }], }).add(geo.lngLat(-100.89888268258135, 46.826680107011924)), ]; const bushes = [ new Tree("Medora Juniper 1", { species: "Medora Juniper", - canopyDiameterMeters: 1.5, - heightMeters: 2.5, + measurements: [{ canopyDiameter: geo.ft(4.92), height: geo.ft(8.2) }], plantedDate: "2021-09-21", pricePaid: 96.50, }).inGroup(juniperTreesGroup).add(geo.lngLat(-100.89880933964611, 46.826705259550664)), new Tree("Medora Juniper 2", { species: "Medora Juniper", - canopyDiameterMeters: 1.5, - heightMeters: 2.5, + measurements: [{ canopyDiameter: geo.ft(4.92), height: geo.ft(8.2) }], plantedDate: "2021-09-21", pricePaid: 96.50, }).inGroup(juniperTreesGroup).add(geo.lngLat(-100.89877004677517, 46.82670396593881)), new Tree("Medora Juniper 3", { species: "Medora Juniper", - canopyDiameterMeters: 1.5, - heightMeters: 2.5, + measurements: [{ canopyDiameter: geo.ft(4.92), height: geo.ft(8.2) }], plantedDate: "2021-09-21", pricePaid: 96.50, }).inGroup(juniperTreesGroup).add(geo.lngLat(-100.898799106227, 46.82669830398086)), new Tree("Medora Juniper 4", { species: "Medora Juniper", - canopyDiameterMeters: 1.5, - heightMeters: 2.5, + measurements: [{ canopyDiameter: geo.ft(4.92), height: geo.ft(8.2) }], plantedDate: "2021-09-21", pricePaid: 96.50, }).inGroup(juniperTreesGroup).add(geo.lngLat(-100.89878871410515, 46.82669251034885)), new Tree("Medora Juniper 5", { species: "Medora Juniper", - canopyDiameterMeters: 1.5, - heightMeters: 2.5, + measurements: [{ canopyDiameter: geo.ft(4.92), height: geo.ft(8.2) }], plantedDate: "2021-09-21", pricePaid: 96.50, }).inGroup(juniperTreesGroup).add(geo.lngLat(-100.8987812086838, 46.82671120797719)), new Tree("Black Currant", { species: "Black Currant", - canopyDiameterMeters: 2, - heightMeters: 2, + measurements: [{ canopyDiameter: geo.ft(6.56), height: geo.ft(6.56) }], gardenOrgId: "87767", }).add(geo.offset(lotCorner), geo.east(geo.ft(107)), geo.south(geo.ft(3))), new Tree("Elderberry", { species: "Elderberry", - canopyDiameterMeters: 3, - heightMeters: 3, + measurements: [{ canopyDiameter: geo.ft(9.84), height: geo.ft(9.84) }], gardenOrgId: "78882", }).add(geo.offset(lotCorner), geo.east(geo.ft(142)), geo.south(geo.ft(52))), new Tree("Spirea", { species: "Spirea", - canopyDiameterMeters: 1.5, - heightMeters: 1, + measurements: [{ canopyDiameter: geo.ft(4.92), height: geo.ft(3.28) }], }).add(geo.offset(lotCorner), geo.east(geo.ft(70)), geo.south(geo.ft(31))), new Tree("Spirea", { species: "Spirea", - canopyDiameterMeters: 1.5, - heightMeters: 1, + measurements: [{ canopyDiameter: geo.ft(4.92), height: geo.ft(3.28) }], }).add(geo.offset(lotCorner), geo.east(geo.ft(74)), geo.south(geo.ft(31))), new Tree("Spirea", { species: "Spirea", - canopyDiameterMeters: 1.5, - heightMeters: 1, + measurements: [{ canopyDiameter: geo.ft(4.92), height: geo.ft(3.28) }], }).add(geo.offset(lotCorner), geo.east(geo.ft(78)), geo.south(geo.ft(31))), ]; diff --git a/src/lib/feature-types.js b/src/lib/feature-types.js index 516dd29..91b145b 100644 --- a/src/lib/feature-types.js +++ b/src/lib/feature-types.js @@ -128,6 +128,9 @@ export const featureTypes = { species: "string", cultivar: "string", gardenOrgId: "string", + measurements: "plantMeasurement[]", + canopyDiameter: "number", + height: "number", note: "string", }, externalLinks: [ @@ -158,6 +161,9 @@ export const featureTypes = { cultivar: "string", bloomColor: "string", bloomSeason: "string", + measurements: "plantMeasurement[]", + canopyDiameter: "number", + height: "number", note: "string", }, style: { @@ -179,6 +185,9 @@ export const featureTypes = { cultivar: "string", bloomColor: "string", bloomSeason: "string", + measurements: "plantMeasurement[]", + canopyDiameter: "number", + height: "number", note: "string", }, style: { @@ -200,14 +209,15 @@ export const featureTypes = { genus: "string", species: "string", cultivar: "string", - canopyDiameterMeters: "number", - heightMeters: "number", + measurements: "plantMeasurement[]", + canopyDiameter: "number", + height: "number", note: "string", }, style: (details) => ({ color: "#426a3d", weight: 1, - radiusMeters: (details.canopyDiameterMeters ?? 1) / 2, + radiusMeters: (details.canopyDiameter ?? 1) / 2, fillColor: "#7e9d58", fillOpacity: 0.62, }), @@ -221,14 +231,15 @@ export const featureTypes = { genus: "string", species: "string", cultivar: "string", - canopyDiameterMeters: "number", - heightMeters: "number", + measurements: "plantMeasurement[]", + canopyDiameter: "number", + height: "number", note: "string", }, style: (details) => ({ color: "#2f6b3d", weight: 1, - radiusMeters: (details.canopyDiameterMeters ?? 2) / 2, + radiusMeters: (details.canopyDiameter ?? 2) / 2, fillColor: "#5c9e64", fillOpacity: 0.55, }), diff --git a/src/lib/yard-features.js b/src/lib/yard-features.js index 17880fd..f113c66 100644 --- a/src/lib/yard-features.js +++ b/src/lib/yard-features.js @@ -1,7 +1,7 @@ import { collectGeoJSON, offset, pointsFromLngLat } from "./geometry.js"; import { getFeatureType, listFeatureTypeSchemas } from "./feature-types.js"; -export const YARD_SCHEMA_VERSION = 2; +export const YARD_SCHEMA_VERSION = 3; class YardFeature { constructor(kind, geometryType, name, details = {}) { @@ -286,7 +286,7 @@ export class FlowerBed extends PolygonFeature { export class Plant extends PointFeature { constructor(name = "Plant", details = {}, kind = "plant") { - super(kind, name, details); + super(kind, name, normalizePlantDetails(details)); } } @@ -324,6 +324,65 @@ export class Tree extends Plant { } } +function normalizePlantDetails(details) { + const { + canopyDiameter, + height, + measurements = [], + ...restDetails + } = details; + const normalizedMeasurements = normalizePlantMeasurements(measurements); + + if (!normalizedMeasurements.length && hasPlantSizeMeasurement(details)) { + normalizedMeasurements.push( + normalizePlantMeasurement({ + canopyDiameter, + height, + }), + ); + } + + const latestMeasurement = normalizedMeasurements.at(-1); + + return { + ...restDetails, + ...(normalizedMeasurements.length ? { measurements: normalizedMeasurements } : {}), + ...(latestMeasurement?.canopyDiameter !== undefined + ? { canopyDiameter: latestMeasurement.canopyDiameter } + : {}), + ...(latestMeasurement?.height !== undefined + ? { height: latestMeasurement.height } + : {}), + }; +} + +function normalizePlantMeasurements(measurements) { + if (!Array.isArray(measurements)) { + throw new Error("Plant measurements must be an array"); + } + + return measurements.map(normalizePlantMeasurement); +} + +function normalizePlantMeasurement(measurement) { + if (!measurement || typeof measurement !== "object") { + throw new Error("Plant measurement entries must be objects"); + } + + const { canopyDiameter, height, measuredDate, ...restMeasurement } = measurement; + + return { + ...restMeasurement, + ...(measuredDate ? { measuredDate } : {}), + ...(canopyDiameter !== undefined ? { canopyDiameter } : {}), + ...(height !== undefined ? { height } : {}), + }; +} + +function hasPlantSizeMeasurement(details) { + return details.canopyDiameter !== undefined || details.height !== undefined; +} + export class Sprinkler extends PointFeature { constructor(name = "Sprinkler", details = {}) { super("sprinkler", name, details); @@ -337,6 +396,11 @@ export function collectYardData(features, groups = []) { plantedDate: "date", removedDate: "date", }; + collection.measurementFields = { + measuredDate: "date", + canopyDiameter: "number", + height: "number", + }; collection.featureTypes = listFeatureTypeSchemas(); collection.groups = groups.map((group) => ({ id: group.id, diff --git a/src/main.js b/src/main.js index adc62d6..9abd1e6 100644 --- a/src/main.js +++ b/src/main.js @@ -305,7 +305,7 @@ function extendBounds(bounds, coordinates) { function buildPopupContent(properties) { const { kind, label, name, plantedDate, removedDate, details, parentId, groupIds } = properties; const entries = Object.entries(details ?? {}).filter( - ([key, value]) => value !== null && value !== undefined && shouldShowDetailField(key), + ([key, value]) => value !== null && value !== undefined && shouldShowDetailField(key, details), ); const lifecycleEntries = []; const relationshipEntries = []; @@ -344,14 +344,23 @@ function buildPopupContent(properties) { return `${title}${subtitle}
${detailRows}${linkRows}
`; } -function shouldShowDetailField(key) { +function shouldShowDetailField(key, details = {}) { + if ( + Array.isArray(details.measurements) && + (key === "canopyDiameter" || key === "height") + ) { + return false; + } + return !key.endsWith("Url") && key !== "gardenOrgId"; } function formatDetailLabel(key) { const labels = { - canopyDiameterMeters: "Canopy diameter", - heightMeters: "Height", + canopyDiameter: "Canopy diameter", + height: "Height", + measurements: "Measurements", + measuredDate: "Measured", plantedDate: "Planted", removedDate: "Removed", commonName: "Common name", @@ -373,7 +382,11 @@ function formatDetailLabel(key) { } function formatDetailValue(key, value) { - if (key === "canopyDiameterMeters" || key === "heightMeters") { + if (key === "measurements") { + return formatPlantMeasurements(value); + } + + if (key === "canopyDiameter" || key === "height") { return `${value} m`; } @@ -384,6 +397,34 @@ function formatDetailValue(key, value) { return String(value); } +function formatPlantMeasurements(measurements) { + if (!Array.isArray(measurements)) { + return String(measurements); + } + + return measurements.map(formatPlantMeasurement).join("; "); +} + +function formatPlantMeasurement(measurement) { + const parts = []; + + if (measurement.measuredDate) { + parts.push(formatDetailValue("measuredDate", measurement.measuredDate)); + } + + if (measurement.canopyDiameter !== undefined) { + parts.push( + `canopy ${formatDetailValue("canopyDiameter", measurement.canopyDiameter)}`, + ); + } + + if (measurement.height !== undefined) { + parts.push(`height ${formatDetailValue("height", measurement.height)}`); + } + + return parts.join(", "); +} + function escapeHtml(value) { return String(value) .replaceAll("&", "&") From ba6c15007683dc2c14475f3509682d95bbbffa67 Mon Sep 17 00:00:00 2001 From: aaronaxvig Date: Tue, 7 Jul 2026 22:01:01 -0500 Subject: [PATCH 15/23] More measurements --- src/data/plants.js | 32 ++++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/src/data/plants.js b/src/data/plants.js index a894afa..d6c7655 100644 --- a/src/data/plants.js +++ b/src/data/plants.js @@ -250,7 +250,9 @@ const trees = [ new Tree("Dwarf Korean Lilac", { species: "Dwarf Korean Lilac", - measurements: [{ canopyDiameter: geo.ft(5.74), height: geo.ft(8.2) }], + measurements: [ + { measuredDate: "2026-07-08", canopyDiameter: geo.ft(5.0), height: geo.ft(6.8) }, + ], gardenOrgId: "79137", plantedDate: "2021-09-21", pricePaid: 218, @@ -258,7 +260,9 @@ const trees = [ new Tree("Dwarf Korean Lilac", { species: "Dwarf Korean Lilac", - measurements: [{ canopyDiameter: geo.ft(5.74), height: geo.ft(8.2) }], + measurements: [ + { measuredDate: "2026-07-08", canopyDiameter: geo.ft(5.5), height: geo.ft(7.5) }, + ], gardenOrgId: "79137", plantedDate: "2021-09-21", pricePaid: 218, @@ -280,6 +284,30 @@ const trees = [ species: "Evergreen", measurements: [{ canopyDiameter: geo.ft(26.25), height: geo.ft(49.21) }], }).add(geo.lngLat(-100.89888268258135, 46.826680107011924)), + + new Tree("Black Walnut from Jean", { + species: "Black Walnut", + measurements: [ + { measuredDate: "2026-07-08", canopyDiameter: geo.ft(2.7), height: geo.ft(2.9) } + ], + plantedDate: "2024-10-01", + }).add(geo.offset(lotCorner), geo.east(geo.ft(2.8)), geo.south(geo.ft(9.2))), + + new Tree("Black Walnut from Jean", { + species: "Black Walnut", + measurements: [ + { measuredDate: "2026-07-08", canopyDiameter: geo.ft(2.5), height: geo.ft(3.5) } + ], + plantedDate: "2024-10-01", + }).add(geo.offset(lotCorner), geo.east(geo.ft(2.6)), geo.south(geo.ft(11.3))), + + new Tree("Black Walnut from Jean", { + species: "Black Walnut", + measurements: [ + { measuredDate: "2026-07-08", canopyDiameter: geo.ft(4), height: geo.ft(4.5) } + ], + plantedDate: "2024-10-01", + }).add(geo.offset(lotCorner), geo.east(geo.ft(1.6)), geo.south(geo.ft(12.7))), ]; const bushes = [ From 100340fbb4e681c0a21c48de09c4b361f7dcc55e Mon Sep 17 00:00:00 2001 From: aaronaxvig Date: Tue, 7 Jul 2026 22:12:28 -0500 Subject: [PATCH 16/23] Measurement --- src/data/plants.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/data/plants.js b/src/data/plants.js index d6c7655..a8eee41 100644 --- a/src/data/plants.js +++ b/src/data/plants.js @@ -185,7 +185,7 @@ const flowers = [ }).add(geo.offset(lotCorner), geo.east(geo.ft(3)), geo.south(geo.ft(17))), new Flower("Peony", { gardenOrgId: "697986", - }).add(geo.offset(lotCorner), geo.east(geo.ft(19)), geo.south(geo.ft(1.5))), + }).add(geo.offset(lotCorner), geo.east(geo.ft(18.7)), geo.south(geo.ft(1.6))), new Flower("Peony", { gardenOrgId: "697986", }).add(geo.offset(lotCorner), geo.east(geo.ft(75)), geo.south(geo.ft(2))), From 900e195926404395ff7d10e6ca671cd8157e81e2 Mon Sep 17 00:00:00 2001 From: aaronaxvig Date: Tue, 7 Jul 2026 22:17:58 -0500 Subject: [PATCH 17/23] Fix measurements in popup details --- src/main.js | 87 ++++++++++++++++++++++++++++++++++++----------------- styles.css | 27 +++++++++++++++++ 2 files changed, 87 insertions(+), 27 deletions(-) diff --git a/src/main.js b/src/main.js index 9abd1e6..33373b0 100644 --- a/src/main.js +++ b/src/main.js @@ -5,6 +5,7 @@ const TILE_MAX_NATIVE_ZOOM = 19; const MAP_MAX_ZOOM = 24; const MAP_ZOOM_STEP = 0.25; const WHEEL_PIXELS_PER_ZOOM_LEVEL = 240; +const FEET_PER_METER = 3.280839895; const TIMELINE_PRESETS = [ { id: "purchase", @@ -330,7 +331,8 @@ function buildPopupContent(properties) { const title = `${escapeHtml(name)}`; const subtitle = name === label ? "" : `
${escapeHtml(label)}
`; const allEntries = [...lifecycleEntries, ...entries, ...relationshipEntries]; - if (allEntries.length === 0 && linkEntries.length === 0) { + const measurementTable = buildMeasurementTable(details?.measurements); + if (allEntries.length === 0 && linkEntries.length === 0 && !measurementTable) { return `${title}${subtitle}`; } @@ -341,12 +343,16 @@ function buildPopupContent(properties) { .map((link) => ``) .join(""); - return `${title}${subtitle}
${detailRows}${linkRows}
`; + return `${title}${subtitle}
${detailRows}${measurementTable}${linkRows}
`; } function shouldShowDetailField(key, details = {}) { + if (key === "measurements") { + return false; + } + if ( - Array.isArray(details.measurements) && + hasDatedMeasurements(details.measurements) && (key === "canopyDiameter" || key === "height") ) { return false; @@ -382,12 +388,8 @@ function formatDetailLabel(key) { } function formatDetailValue(key, value) { - if (key === "measurements") { - return formatPlantMeasurements(value); - } - if (key === "canopyDiameter" || key === "height") { - return `${value} m`; + return formatMetersAsFeet(value); } if (key === "offsetFeet" || key === "spacingFeet") { @@ -397,32 +399,63 @@ function formatDetailValue(key, value) { return String(value); } -function formatPlantMeasurements(measurements) { - if (!Array.isArray(measurements)) { - return String(measurements); - } - - return measurements.map(formatPlantMeasurement).join("; "); +function formatMetersAsFeet(value) { + return `${formatNumber(value * FEET_PER_METER)} ft`; } -function formatPlantMeasurement(measurement) { - const parts = []; +function formatNumber(value) { + return Number(value.toFixed(1)).toString(); +} - if (measurement.measuredDate) { - parts.push(formatDetailValue("measuredDate", measurement.measuredDate)); +function buildMeasurementTable(measurements) { + const datedMeasurements = getDatedMeasurements(measurements); + if (!datedMeasurements.length) { + return ""; } - if (measurement.canopyDiameter !== undefined) { - parts.push( - `canopy ${formatDetailValue("canopyDiameter", measurement.canopyDiameter)}`, - ); + const rows = datedMeasurements + .map( + (measurement) => ` + + ${escapeHtml(measurement.measuredDate)} + ${escapeHtml(formatOptionalMeasurement(measurement.canopyDiameter))} + ${escapeHtml(formatOptionalMeasurement(measurement.height))} + + `, + ) + .join(""); + + return ` +
+
${escapeHtml(formatDetailLabel("measurements"))}:
+ + + + + + + + + ${rows} +
DateCanopy dia.Height
+
+ `; +} + +function formatOptionalMeasurement(value) { + return value === undefined ? "" : formatMetersAsFeet(value); +} + +function hasDatedMeasurements(measurements) { + return getDatedMeasurements(measurements).length > 0; +} + +function getDatedMeasurements(measurements) { + if (!Array.isArray(measurements)) { + return []; } - if (measurement.height !== undefined) { - parts.push(`height ${formatDetailValue("height", measurement.height)}`); - } - - return parts.join(", "); + return measurements.filter((measurement) => measurement.measuredDate); } function escapeHtml(value) { diff --git a/styles.css b/styles.css index 0bdf2fd..bf8917d 100644 --- a/styles.css +++ b/styles.css @@ -129,6 +129,33 @@ main { font-family: "Iowan Old Style", "Palatino Linotype", "URW Palladio L", serif; } +.measurement-table-block { + margin-top: 0.35rem; +} + +.measurement-table { + width: 100%; + margin-top: 0.15rem; + border-collapse: collapse; + font-size: 0.92em; +} + +.measurement-table th, +.measurement-table td { + padding: 0.12rem 0.35rem 0.12rem 0; + border-bottom: 1px solid rgba(87, 68, 38, 0.18); + text-align: left; + white-space: nowrap; +} + +.measurement-table th { + font-weight: 700; +} + +.measurement-table tbody tr:last-child td { + border-bottom: 0; +} + .timeline-control { display: grid; gap: 0.45rem; From 6bd490087bbab016c564231116543687356e5e8d Mon Sep 17 00:00:00 2001 From: aaronaxvig Date: Sat, 11 Jul 2026 20:50:43 -0500 Subject: [PATCH 18/23] Using checkbox tree control --- AGENTS.md | 16 +- README.md | 6 +- index.html | 6 +- src/data/buildings.js | 14 +- src/data/grid.js | 4 +- src/data/pavement.js | 17 +- src/data/plants.js | 27 ++- src/data/yard.js | 3 +- src/lib/checkbox-tree/checkbox-tree.css | 78 +++++++ src/lib/checkbox-tree/checkbox-tree.js | 290 ++++++++++++++++++++++++ src/lib/feature-types.js | 58 +++++ src/lib/yard-features.js | 19 +- src/main.js | 213 +++++++++++++---- styles.css | 47 ++-- 14 files changed, 680 insertions(+), 118 deletions(-) create mode 100644 src/lib/checkbox-tree/checkbox-tree.css create mode 100644 src/lib/checkbox-tree/checkbox-tree.js diff --git a/AGENTS.md b/AGENTS.md index a154484..fd64d0e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,14 +19,15 @@ Core files: - `src/lib/feature-types.js`: feature-kind labels, geometry constraints, detail field schemas, and style defaults. - `src/lib/yard-features.js`: typed feature classes, stable ids, groups, parent-child relationships, schema metadata, GeoJSON export. - `src/data/*.js`: yard content split by domain. -- `src/main.js`: Leaflet map, legend, popup rendering. +- `src/main.js`: Leaflet map, visibility controls, popup rendering. Top-level exported data shape from `src/data/yard.js`: - `type: "FeatureCollection"` -- `schemaVersion: 2` +- `schemaVersion: 4` - `lifecycleFields: { plantedDate: "date", removedDate: "date" }` - `featureTypes: [...]` +- `featureCategories: [...]` - `features: [...]` - `groups: [...]` @@ -53,8 +54,8 @@ Each feature currently exports: 3. Local rotation is per anchor. `offset(...)` reads `assumedNorthClockwiseDegrees` from the anchor point used as the origin. Do not reintroduce a global lot rotation unless there is a strong reason. -4. Grouping and parent-child metadata are already part of the data model. - Future visibility controls should consume `groupIds` and `parentId` rather than inventing a second relationship system. +4. Feature categories, grouping, and parent-child metadata are part of the data model. + Top-level visibility categories come from `featureCategories` and each feature type's `categoryId`. Visibility controls consume those schemas and `groupIds` rather than inventing a second relationship system. 5. The GPKG was only a seed source. The app does not depend on GIS tooling at runtime. @@ -70,14 +71,13 @@ Each feature currently exports: ## What is already modeled - Porch and deck are children of the main house and also part of the `House Parts` group. -- Medora junipers are part of the `Juniper Trees` group. +- Medora junipers are bushes in the `Junipers` group. - A test tree exists that is offset from the top-left lot corner using per-anchor rotation. ## Likely next steps -1. Add show/hide controls driven by `yardGeoJSON.groups` and possibly `parentId`. -2. Decide whether toggles should be by feature kind, by group, by parent object, or a combination. -3. If closer basemap zoom fidelity matters, migrate from raster OSM tiles to a vector tile or self-hosted basemap. +1. Decide whether parent-child feature relationships should also affect the visibility hierarchy. +2. If closer basemap zoom fidelity matters, migrate from raster OSM tiles to a vector tile or self-hosted basemap. ## Useful validation commands diff --git a/README.md b/README.md index edc1a4f..7610403 100644 --- a/README.md +++ b/README.md @@ -34,13 +34,13 @@ The example yard is now split by concern: - `src/data/plants.js` - `src/data/irrigation.js` -The typed feature registry lives in `src/lib/feature-types.js`, feature classes live in `src/lib/yard-features.js`, and shared coordinate helpers live in `src/lib/geometry.js`. Feature type definitions include the label, required geometry type, known detail fields, and default Leaflet style. +The typed feature registry lives in `src/lib/feature-types.js`, feature classes live in `src/lib/yard-features.js`, and shared coordinate helpers live in `src/lib/geometry.js`. Feature type definitions include the top-level category, label, required geometry type, known detail fields, and default Leaflet style. Local `north`, `south`, `east`, and `west` offsets are rotated per anchor. In this example, `topLeftLotCorner` in `src/data/anchors.js` carries `assumedNorthClockwiseDegrees` from `src/data/settings.js`. A negative value means that anchor's local north is counterclockwise from true north, so local east drifts a bit toward true north. Most example geometry was copied from `buildings.gpkg` and preserved as JS-authored coordinates. Tree records also carry custom attributes like canopy diameter, height, and lifecycle dates for popup rendering. -Feature relationships are exported with each GeoJSON feature as stable `id`, `parentId`, and `groupIds` properties. Each feature also exports universal `plantedDate` and `removedDate` lifecycle fields for timeline filtering. The collection exposes `schemaVersion`, `lifecycleFields`, `featureTypes`, and `groups` at the top level. Style is resolved from the feature type registry at render time rather than stored on each exported feature. The current sample marks the porch and deck as part of the house and groups the Medora junipers under `Juniper Trees`. +Feature relationships are exported with each GeoJSON feature as stable `id`, `parentId`, and `groupIds` properties. Each feature also exports universal `plantedDate` and `removedDate` lifecycle fields for timeline filtering. The collection exposes `schemaVersion`, `lifecycleFields`, `featureCategories`, `featureTypes`, and `groups` at the top level. Categories use stable IDs such as `plants`, `structures`, `infrastructure`, and `reference`; feature types and groups refer to them with `categoryId`. Style is resolved from the feature type registry at render time rather than stored on each exported feature. The current sample marks the porch and deck as part of the house and groups the Medora junipers under `Junipers`. ## Project structure @@ -55,7 +55,7 @@ Feature relationships are exported with each GeoJSON feature as stable `id`, `pa - `src/data/plants.js`: flower beds, trees, and tree groups. - `src/data/irrigation.js`: sprinkler features. - `src/data/yard.js`: top-level yard collection assembly. -- `src/main.js`: Leaflet rendering, legend generation, and popup rendering. +- `src/main.js`: Leaflet rendering, visibility controls, and popup rendering. ## Key decisions diff --git a/index.html b/index.html index 25ae2c2..ec7816c 100644 --- a/index.html +++ b/index.html @@ -5,6 +5,7 @@ A Plot of Plants + @@ -17,7 +18,10 @@ a variety of trees, shrubs, and flowers. There are many varieties of daylilies.

-
+
+

Map features

+
+
diff --git a/src/data/buildings.js b/src/data/buildings.js index 9a7b69d..facf09d 100644 --- a/src/data/buildings.js +++ b/src/data/buildings.js @@ -1,8 +1,14 @@ import { Deck, defineGroup, Garage, House, Porch } from "../lib/yard-features.js"; -export const housePartsGroup = defineGroup("group:house-parts", "House Parts"); +export const buildingsGroup = defineGroup("group:buildings", "Buildings", { + categoryId: "structures", +}); +export const housePartsGroup = defineGroup("group:house-parts", "House Parts", { + categoryId: "structures", + parentGroupId: buildingsGroup.id, +}); -const house = new House().withId("feature:house-main").trace([ +const house = new House().withId("feature:house-main").inGroup(buildingsGroup).trace([ [-100.89883475400929, 46.826808023423844], [-100.8986668040935, 46.826830709973976], [-100.89866031675769, 46.826807530237865], @@ -11,7 +17,7 @@ const house = new House().withId("feature:house-main").trace([ [-100.89881168792644, 46.82673552503637], ]); -const garage = new Garage().trace([ +const garage = new Garage().inGroup(buildingsGroup).trace([ [-100.89850173743805, 46.82679569377302], [-100.89839289435957, 46.82681098253964], [-100.89837054909181, 46.82674095008918], @@ -39,4 +45,4 @@ const deck = new Deck().childOf(house).inGroup(housePartsGroup).trace([ ]); export const buildingFeatures = [house, garage, porch, deck]; -export const buildingGroups = [housePartsGroup]; \ No newline at end of file +export const buildingGroups = [buildingsGroup, housePartsGroup]; diff --git a/src/data/grid.js b/src/data/grid.js index 3e199dd..c56040a 100644 --- a/src/data/grid.js +++ b/src/data/grid.js @@ -8,7 +8,9 @@ const MAX_EAST_FEET = 180; const MIN_NORTH_FEET = -140; const MAX_NORTH_FEET = 20; -export const yardGridGroup = defineGroup("group:yard-grid", "Yard Grid"); +export const yardGridGroup = defineGroup("group:yard-grid", "Yard Grid", { + categoryId: "reference", +}); function localPoint(eastFeet, northFeet) { return geo.offset( diff --git a/src/data/pavement.js b/src/data/pavement.js index 4008cdd..fd11297 100644 --- a/src/data/pavement.js +++ b/src/data/pavement.js @@ -1,6 +1,10 @@ -import { Driveway, Walkway } from "../lib/yard-features.js"; +import { defineGroup, Driveway, Walkway } from "../lib/yard-features.js"; -const frontWalk = new Walkway("Front walk").trace([ +export const pavedSurfacesGroup = defineGroup("group:paved-surfaces", "Paved Surfaces", { + categoryId: "structures", +}); + +const frontWalk = new Walkway("Front walk").inGroup(pavedSurfacesGroup).trace([ [-100.89894913835376, 46.82676927748655], [-100.89887345276941, 46.82677975769509], [-100.89887849847504, 46.82679985503042], @@ -22,14 +26,14 @@ const frontWalk = new Walkway("Front walk").trace([ [-100.8989459622624, 46.826758196205], ]); -const driveway = new Driveway().trace([ +const driveway = new Driveway().inGroup(pavedSurfacesGroup).trace([ [-100.89851482473702, 46.82685818966149], [-100.8984941013032, 46.826796541436565], [-100.89837336477572, 46.82681355635369], [-100.89839320971627, 46.8268766687031], ]); -const garageWalk = new Walkway("Garage walk").trace([ +const garageWalk = new Walkway("Garage walk").inGroup(pavedSurfacesGroup).trace([ [-100.89862628077022, 46.82677780036217], [-100.8986050167251, 46.82677755376906], [-100.89853023215954, 46.82678544474858], @@ -41,11 +45,12 @@ const garageWalk = new Walkway("Garage walk").trace([ [-100.89862267669479, 46.82676559400101], ]); -const stepPad = new Walkway("Step pad").trace([ +const stepPad = new Walkway("Step pad").inGroup(pavedSurfacesGroup).trace([ [-100.89837579752665, 46.82675708653554], [-100.89835705633432, 46.826759675764286], [-100.89835309185132, 46.82674722280572], [-100.89837111222856, 46.82674500346631], ]); -export const pavementFeatures = [frontWalk, driveway, garageWalk, stepPad]; \ No newline at end of file +export const pavementFeatures = [frontWalk, driveway, garageWalk, stepPad]; +export const pavementGroups = [pavedSurfacesGroup]; diff --git a/src/data/plants.js b/src/data/plants.js index a8eee41..de07a84 100644 --- a/src/data/plants.js +++ b/src/data/plants.js @@ -1,8 +1,11 @@ -import { Daylily, defineGroup, Flower, FlowerBed, Tree } from "../lib/yard-features.js"; +import { Bush, Daylily, defineGroup, Flower, FlowerBed, Tree } from "../lib/yard-features.js"; import * as geo from "../lib/geometry.js"; import { lotCorner } from "./anchors.js"; -export const juniperTreesGroup = defineGroup("group:juniper-trees", "Juniper Trees"); +export const juniperTreesGroup = defineGroup("group:juniper-trees", "Junipers", { + categoryId: "plants", + parentKind: "bush", +}); const flowerBeds = [ new FlowerBed("Herbs").trace([ @@ -311,55 +314,55 @@ const trees = [ ]; const bushes = [ - new Tree("Medora Juniper 1", { + new Bush("Medora Juniper 1", { species: "Medora Juniper", measurements: [{ canopyDiameter: geo.ft(4.92), height: geo.ft(8.2) }], plantedDate: "2021-09-21", pricePaid: 96.50, }).inGroup(juniperTreesGroup).add(geo.lngLat(-100.89880933964611, 46.826705259550664)), - new Tree("Medora Juniper 2", { + new Bush("Medora Juniper 2", { species: "Medora Juniper", measurements: [{ canopyDiameter: geo.ft(4.92), height: geo.ft(8.2) }], plantedDate: "2021-09-21", pricePaid: 96.50, }).inGroup(juniperTreesGroup).add(geo.lngLat(-100.89877004677517, 46.82670396593881)), - new Tree("Medora Juniper 3", { + new Bush("Medora Juniper 3", { species: "Medora Juniper", measurements: [{ canopyDiameter: geo.ft(4.92), height: geo.ft(8.2) }], plantedDate: "2021-09-21", pricePaid: 96.50, }).inGroup(juniperTreesGroup).add(geo.lngLat(-100.898799106227, 46.82669830398086)), - new Tree("Medora Juniper 4", { + new Bush("Medora Juniper 4", { species: "Medora Juniper", measurements: [{ canopyDiameter: geo.ft(4.92), height: geo.ft(8.2) }], plantedDate: "2021-09-21", pricePaid: 96.50, }).inGroup(juniperTreesGroup).add(geo.lngLat(-100.89878871410515, 46.82669251034885)), - new Tree("Medora Juniper 5", { + new Bush("Medora Juniper 5", { species: "Medora Juniper", measurements: [{ canopyDiameter: geo.ft(4.92), height: geo.ft(8.2) }], plantedDate: "2021-09-21", pricePaid: 96.50, }).inGroup(juniperTreesGroup).add(geo.lngLat(-100.8987812086838, 46.82671120797719)), - new Tree("Black Currant", { + new Bush("Black Currant", { species: "Black Currant", measurements: [{ canopyDiameter: geo.ft(6.56), height: geo.ft(6.56) }], gardenOrgId: "87767", }).add(geo.offset(lotCorner), geo.east(geo.ft(107)), geo.south(geo.ft(3))), - new Tree("Elderberry", { + new Bush("Elderberry", { species: "Elderberry", measurements: [{ canopyDiameter: geo.ft(9.84), height: geo.ft(9.84) }], gardenOrgId: "78882", }).add(geo.offset(lotCorner), geo.east(geo.ft(142)), geo.south(geo.ft(52))), - new Tree("Spirea", { + new Bush("Spirea", { species: "Spirea", measurements: [{ canopyDiameter: geo.ft(4.92), height: geo.ft(3.28) }], }).add(geo.offset(lotCorner), geo.east(geo.ft(70)), geo.south(geo.ft(31))), - new Tree("Spirea", { + new Bush("Spirea", { species: "Spirea", measurements: [{ canopyDiameter: geo.ft(4.92), height: geo.ft(3.28) }], }).add(geo.offset(lotCorner), geo.east(geo.ft(74)), geo.south(geo.ft(31))), - new Tree("Spirea", { + new Bush("Spirea", { species: "Spirea", measurements: [{ canopyDiameter: geo.ft(4.92), height: geo.ft(3.28) }], }).add(geo.offset(lotCorner), geo.east(geo.ft(78)), geo.south(geo.ft(31))), diff --git a/src/data/yard.js b/src/data/yard.js index 9f4dda3..34fabbe 100644 --- a/src/data/yard.js +++ b/src/data/yard.js @@ -3,7 +3,7 @@ import { boundaryFeatures } from "./boundaries.js"; import { buildingFeatures, buildingGroups } from "./buildings.js"; import { gridFeatures, gridGroups } from "./grid.js"; import { irrigationFeatures } from "./irrigation.js"; -import { pavementFeatures } from "./pavement.js"; +import { pavementFeatures, pavementGroups } from "./pavement.js"; import { plantFeatures, plantGroups } from "./plants.js"; export const yardGeoJSON = collectYardData( @@ -18,6 +18,7 @@ export const yardGeoJSON = collectYardData( [ ...buildingGroups, ...gridGroups, + ...pavementGroups, ...plantGroups, ], ); diff --git a/src/lib/checkbox-tree/checkbox-tree.css b/src/lib/checkbox-tree/checkbox-tree.css new file mode 100644 index 0000000..08a5842 --- /dev/null +++ b/src/lib/checkbox-tree/checkbox-tree.css @@ -0,0 +1,78 @@ +.checkbox-tree { + --checkbox-tree-indent: 18px; + --checkbox-tree-toggle-size: 18px; + --checkbox-tree-hover-color: #2c3e50; + font-size: 14px; + line-height: 1.4; +} + +.checkbox-tree__item { + margin: 1px 0; +} + +.checkbox-tree__row { + display: flex; + align-items: center; + min-height: 24px; +} + +.checkbox-tree__toggle, +.checkbox-tree__toggle-spacer { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 var(--checkbox-tree-toggle-size); + width: var(--checkbox-tree-toggle-size); + height: var(--checkbox-tree-toggle-size); + margin-right: 2px; +} + +.checkbox-tree__toggle { + padding: 0; + border: 0; + background: transparent; + color: inherit; + cursor: pointer; + font: inherit; +} + +.checkbox-tree__toggle:focus-visible { + outline: 2px solid currentColor; + outline-offset: 1px; +} + +.checkbox-tree__label { + display: inline-flex; + align-items: center; + padding: 2px 0; + cursor: pointer; + user-select: none; +} + +.checkbox-tree__item:has(> .checkbox-tree__children) > .checkbox-tree__row > .checkbox-tree__label { + font-weight: 500; +} + +.checkbox-tree__label:hover { + color: var(--checkbox-tree-hover-color); +} + +.checkbox-tree__checkbox { + flex-shrink: 0; + width: 16px; + height: 16px; + margin: 0 4px 0 0; + cursor: pointer; +} + +.checkbox-tree__checkbox:indeterminate { + accent-color: #f39c12; +} + +.checkbox-tree__children { + margin-left: var(--checkbox-tree-indent); +} + +.checkbox-tree__children[hidden] { + display: none; +} diff --git a/src/lib/checkbox-tree/checkbox-tree.js b/src/lib/checkbox-tree/checkbox-tree.js new file mode 100644 index 0000000..837f210 --- /dev/null +++ b/src/lib/checkbox-tree/checkbox-tree.js @@ -0,0 +1,290 @@ +const DEFAULT_OPTIONS = { + initiallyCollapsed: true, + initiallySelected: false, + storageKey: null, + onSelectionChange: null +}; + +export class CheckboxTree { + constructor(container, options = {}) { + if (!(container instanceof Element)) { + throw new TypeError('CheckboxTree requires a container element.'); + } + + this.container = container; + this.options = { ...DEFAULT_OPTIONS, ...options }; + this.nodesById = new Map(); + this.rootNodes = []; + this.handleChange = this.handleChange.bind(this); + this.handleClick = this.handleClick.bind(this); + + this.container.classList.add('checkbox-tree'); + this.container.addEventListener('change', this.handleChange); + this.container.addEventListener('click', this.handleClick); + } + + setData(nodes) { + if (!Array.isArray(nodes)) { + throw new TypeError('CheckboxTree data must be an array of nodes.'); + } + + this.nodesById.clear(); + this.rootNodes = nodes; + this.indexNodes(nodes); + this.render(); + this.updateAllParentCheckboxes(); + this.restoreState(); + } + + getSelectedIds() { + return this.getSelectedNodes().map(node => node.id); + } + + getSelectedNodes() { + return Array.from(this.container.querySelectorAll( + '.checkbox-tree__checkbox:checked[data-selectable="true"]' + )).map(checkbox => this.nodesById.get(checkbox.dataset.nodeId)).filter(Boolean); + } + + setSelectedIds(ids, { notify = false } = {}) { + const selectedIds = new Set(ids); + this.container.querySelectorAll('.checkbox-tree__checkbox').forEach(checkbox => { + checkbox.checked = checkbox.dataset.selectable === 'true' && selectedIds.has(checkbox.dataset.nodeId); + checkbox.indeterminate = false; + }); + this.updateAllParentCheckboxes(); + this.saveState(); + + if (notify) { + this.notifySelectionChange(); + } + } + + destroy() { + this.container.removeEventListener('change', this.handleChange); + this.container.removeEventListener('click', this.handleClick); + this.container.classList.remove('checkbox-tree'); + this.container.replaceChildren(); + this.nodesById.clear(); + } + + indexNodes(nodes) { + nodes.forEach(node => { + if (!node || typeof node.id !== 'string' || !node.id || typeof node.label !== 'string') { + throw new TypeError('Each CheckboxTree node requires non-empty string id and label properties.'); + } + if (this.nodesById.has(node.id)) { + throw new Error(`Duplicate CheckboxTree node id: ${node.id}`); + } + + this.nodesById.set(node.id, node); + if (node.children !== undefined) { + if (!Array.isArray(node.children)) { + throw new TypeError(`CheckboxTree node children must be an array: ${node.id}`); + } + this.indexNodes(node.children); + } + }); + } + + render() { + const fragment = document.createDocumentFragment(); + this.rootNodes.forEach(node => fragment.appendChild(this.createNodeElement(node))); + this.container.replaceChildren(fragment); + } + + createNodeElement(node) { + const item = document.createElement('div'); + item.className = 'checkbox-tree__item'; + item.dataset.nodeId = node.id; + + const row = document.createElement('div'); + row.className = 'checkbox-tree__row'; + const hasChildren = Array.isArray(node.children) && node.children.length > 0; + + if (hasChildren) { + const toggle = document.createElement('button'); + toggle.type = 'button'; + toggle.className = 'checkbox-tree__toggle'; + toggle.dataset.action = 'toggle'; + toggle.setAttribute('aria-label', `Expand ${node.label}`); + toggle.setAttribute('aria-expanded', 'false'); + toggle.textContent = 'â–¶'; + row.appendChild(toggle); + } else { + const spacer = document.createElement('span'); + spacer.className = 'checkbox-tree__toggle-spacer'; + spacer.setAttribute('aria-hidden', 'true'); + row.appendChild(spacer); + } + + const label = document.createElement('label'); + label.className = 'checkbox-tree__label'; + if (node.selectable !== false) { + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.className = 'checkbox-tree__checkbox'; + checkbox.dataset.nodeId = node.id; + checkbox.dataset.selectable = 'true'; + checkbox.disabled = node.disabled === true; + checkbox.checked = this.options.initiallySelected && !checkbox.disabled; + label.appendChild(checkbox); + } + label.appendChild(document.createTextNode(node.label)); + row.appendChild(label); + item.appendChild(row); + + if (hasChildren) { + const children = document.createElement('div'); + children.className = 'checkbox-tree__children'; + children.hidden = this.options.initiallyCollapsed; + node.children.forEach(child => children.appendChild(this.createNodeElement(child))); + item.appendChild(children); + + if (!this.options.initiallyCollapsed) { + this.setExpanded(item, true); + } + } + + return item; + } + + handleClick(event) { + const toggle = event.target.closest('.checkbox-tree__toggle[data-action="toggle"]'); + if (!toggle || !this.container.contains(toggle)) { + return; + } + + const item = toggle.closest('.checkbox-tree__item'); + this.setExpanded(item, toggle.getAttribute('aria-expanded') !== 'true'); + this.saveState(); + } + + handleChange(event) { + const checkbox = event.target.closest('.checkbox-tree__checkbox'); + if (!checkbox || !this.container.contains(checkbox)) { + return; + } + + const item = checkbox.closest('.checkbox-tree__item'); + const children = this.directChildrenContainer(item); + if (children) { + children.querySelectorAll('.checkbox-tree__checkbox:not(:disabled)').forEach(child => { + child.checked = checkbox.checked; + child.indeterminate = false; + }); + } + + checkbox.indeterminate = false; + this.updateAncestorCheckboxes(item); + this.saveState(); + this.notifySelectionChange(); + } + + notifySelectionChange() { + if (typeof this.options.onSelectionChange === 'function') { + this.options.onSelectionChange({ + selectedIds: this.getSelectedIds(), + selectedNodes: this.getSelectedNodes() + }); + } + } + + setExpanded(item, expanded) { + const toggle = item.querySelector(':scope > .checkbox-tree__row > .checkbox-tree__toggle'); + const children = this.directChildrenContainer(item); + if (!toggle || !children) { + return; + } + + children.hidden = !expanded; + toggle.setAttribute('aria-expanded', String(expanded)); + const label = this.nodesById.get(item.dataset.nodeId)?.label ?? 'branch'; + toggle.setAttribute('aria-label', `${expanded ? 'Collapse' : 'Expand'} ${label}`); + toggle.textContent = expanded ? 'â–¼' : 'â–¶'; + } + + directChildrenContainer(item) { + return item.querySelector(':scope > .checkbox-tree__children'); + } + + directCheckbox(item) { + return item.querySelector(':scope > .checkbox-tree__row .checkbox-tree__checkbox'); + } + + updateAncestorCheckboxes(item) { + let parentItem = item.parentElement?.closest('.checkbox-tree__item'); + while (parentItem && this.container.contains(parentItem)) { + this.updateParentCheckbox(parentItem); + parentItem = parentItem.parentElement?.closest('.checkbox-tree__item'); + } + } + + updateAllParentCheckboxes() { + const parents = Array.from(this.container.querySelectorAll('.checkbox-tree__item')) + .filter(item => this.directChildrenContainer(item)); + parents.reverse().forEach(item => this.updateParentCheckbox(item)); + } + + updateParentCheckbox(item) { + const checkbox = this.directCheckbox(item); + const children = this.directChildrenContainer(item); + if (!checkbox || !children) { + return; + } + + const childCheckboxes = Array.from(children.querySelectorAll( + ':scope > .checkbox-tree__item > .checkbox-tree__row .checkbox-tree__checkbox' + )).filter(child => !child.disabled); + const hasSelection = childCheckboxes.some(child => child.checked || child.indeterminate); + const allSelected = childCheckboxes.length > 0 && childCheckboxes.every(child => child.checked); + checkbox.checked = allSelected; + checkbox.indeterminate = hasSelection && !allSelected; + } + + saveState() { + if (!this.options.storageKey) { + return; + } + + const collapsedIds = Array.from(this.container.querySelectorAll('.checkbox-tree__children[hidden]')) + .map(children => children.parentElement.dataset.nodeId); + try { + localStorage.setItem(this.options.storageKey, JSON.stringify({ + version: 1, + selectedIds: this.getSelectedIds(), + collapsedIds + })); + } catch { + // Persistence is optional; storage may be disabled or full. + } + } + + restoreState() { + if (!this.options.storageKey) { + return; + } + + let state; + try { + state = JSON.parse(localStorage.getItem(this.options.storageKey)); + } catch { + return; + } + if (!state || typeof state !== 'object') { + return; + } + + if (Array.isArray(state.selectedIds)) { + this.setSelectedIds(state.selectedIds); + } + if (Array.isArray(state.collapsedIds)) { + const collapsedIds = new Set(state.collapsedIds); + this.container.querySelectorAll('.checkbox-tree__item').forEach(item => { + if (this.directChildrenContainer(item)) { + this.setExpanded(item, !collapsedIds.has(item.dataset.nodeId)); + } + }); + } + } +} diff --git a/src/lib/feature-types.js b/src/lib/feature-types.js index 91b145b..dc0c27f 100644 --- a/src/lib/feature-types.js +++ b/src/lib/feature-types.js @@ -1,6 +1,14 @@ +export const featureCategories = { + plants: { label: "Plants", order: 10 }, + structures: { label: "Structures", order: 20 }, + infrastructure: { label: "Infrastructure", order: 30 }, + reference: { label: "Map Reference", order: 40 }, +}; + export const featureTypes = { lotBoundary: { label: "Lot Boundary", + categoryId: "reference", geometryType: "Polygon", detailFields: {}, style: { @@ -13,6 +21,7 @@ export const featureTypes = { }, fence: { label: "Fence", + categoryId: "structures", geometryType: "LineString", detailFields: { fenceType: "string", @@ -25,6 +34,7 @@ export const featureTypes = { }, yardGrid: { label: "Yard Grid", + categoryId: "reference", geometryType: "LineString", detailFields: { axis: "string", @@ -40,6 +50,8 @@ export const featureTypes = { }, house: { label: "House", + collectionLabel: "Houses", + categoryId: "structures", geometryType: "Polygon", detailFields: {}, style: { @@ -51,6 +63,8 @@ export const featureTypes = { }, garage: { label: "Garage", + collectionLabel: "Garages", + categoryId: "structures", geometryType: "Polygon", detailFields: {}, style: { @@ -62,6 +76,7 @@ export const featureTypes = { }, porch: { label: "Porch", + categoryId: "structures", geometryType: "Polygon", detailFields: {}, style: { @@ -73,6 +88,7 @@ export const featureTypes = { }, deck: { label: "Deck", + categoryId: "structures", geometryType: "Polygon", detailFields: {}, style: { @@ -84,6 +100,8 @@ export const featureTypes = { }, walkway: { label: "Walkway", + collectionLabel: "Walkways", + categoryId: "structures", geometryType: "Polygon", detailFields: {}, style: { @@ -95,6 +113,8 @@ export const featureTypes = { }, driveway: { label: "Driveway", + collectionLabel: "Driveways", + categoryId: "structures", geometryType: "Polygon", detailFields: {}, style: { @@ -106,6 +126,8 @@ export const featureTypes = { }, flowerBed: { label: "Flower Bed", + collectionLabel: "Flower Beds", + categoryId: "structures", geometryType: "Polygon", detailFields: { soil: "string", @@ -121,6 +143,7 @@ export const featureTypes = { }, plant: { label: "Plant", + categoryId: "plants", geometryType: "Point", detailFields: { commonName: "string", @@ -152,6 +175,7 @@ export const featureTypes = { }, flower: { label: "Flower", + collectionLabel: "Flowers", parentKind: "plant", geometryType: "Point", detailFields: { @@ -177,6 +201,7 @@ export const featureTypes = { }, daylily: { label: "Daylily", + collectionLabel: "Daylilies", parentKind: "flower", geometryType: "Point", detailFields: { @@ -202,6 +227,7 @@ export const featureTypes = { }, shrub: { label: "Shrub", + collectionLabel: "Shrubs", parentKind: "plant", geometryType: "Point", detailFields: { @@ -224,6 +250,7 @@ export const featureTypes = { }, tree: { label: "Tree", + collectionLabel: "Trees", parentKind: "plant", geometryType: "Point", detailFields: { @@ -246,6 +273,7 @@ export const featureTypes = { }, sprinkler: { label: "Sprinkler", + categoryId: "infrastructure", geometryType: "Point", detailFields: { zone: "string", @@ -259,6 +287,20 @@ export const featureTypes = { fillOpacity: 0.95, }, }, + bush: { + label: "Bush", + collectionLabel: "Bushes", + parentKind: "plant", + geometryType: "Point", + detailFields: {}, + style: (details) => ({ + color: "#426a3d", + weight: 1, + radiusMeters: (details.canopyDiameter ?? 1) / 2, + fillColor: "#7e9d58", + fillOpacity: 0.62, + }), + }, }; export function getFeatureType(kind) { @@ -274,6 +316,8 @@ export function listFeatureTypeSchemas() { return Object.entries(featureTypes).map(([kind, definition]) => ({ kind, label: definition.label, + collectionLabel: definition.collectionLabel ?? definition.label, + categoryId: getFeatureCategoryId(kind), parentKind: definition.parentKind ?? null, geometryType: definition.geometryType, detailFields: getInheritedDetailFields(kind), @@ -283,6 +327,12 @@ export function listFeatureTypeSchemas() { })); } +export function listFeatureCategorySchemas() { + return Object.entries(featureCategories) + .map(([id, definition]) => ({ id, ...definition })) + .sort((left, right) => left.order - right.order); +} + export function resolveFeatureStyle(kind, details = {}) { const { style } = getFeatureType(kind); return typeof style === "function" ? style(details) : { ...style }; @@ -310,6 +360,14 @@ function getFeatureTypeLineage(kind) { return lineage; } +function getFeatureCategoryId(kind) { + const definition = [...getFeatureTypeLineage(kind)].reverse().find((entry) => entry.categoryId); + if (!definition || !featureCategories[definition.categoryId]) { + throw new Error(`Feature type has an unknown or missing category: ${kind}`); + } + return definition.categoryId; +} + function getInheritedDetailFields(kind) { return Object.assign( {}, diff --git a/src/lib/yard-features.js b/src/lib/yard-features.js index f113c66..9f2e30a 100644 --- a/src/lib/yard-features.js +++ b/src/lib/yard-features.js @@ -1,7 +1,11 @@ import { collectGeoJSON, offset, pointsFromLngLat } from "./geometry.js"; -import { getFeatureType, listFeatureTypeSchemas } from "./feature-types.js"; +import { + getFeatureType, + listFeatureCategorySchemas, + listFeatureTypeSchemas, +} from "./feature-types.js"; -export const YARD_SCHEMA_VERSION = 3; +export const YARD_SCHEMA_VERSION = 4; class YardFeature { constructor(kind, geometryType, name, details = {}) { @@ -155,6 +159,8 @@ export function defineGroup(id, label, options = {}) { return { id, label, + categoryId: options.categoryId ?? null, + parentKind: options.parentKind ?? null, parentGroupId: options.parentGroupId ?? null, }; } @@ -307,14 +313,14 @@ export class Daylily extends Flower { } export class Shrub extends Plant { - constructor(name = "Shrub", details = {}) { - super(name, details, "shrub"); + constructor(name = "Shrub", details = {}, kind = "shrub") { + super(name, details, kind); } } export class Bush extends Shrub { constructor(name = "Bush", details = {}) { - super(name, details); + super(name, details, "bush"); } } @@ -402,9 +408,12 @@ export function collectYardData(features, groups = []) { height: "number", }; collection.featureTypes = listFeatureTypeSchemas(); + collection.featureCategories = listFeatureCategorySchemas(); collection.groups = groups.map((group) => ({ id: group.id, label: group.label, + categoryId: group.categoryId, + parentKind: group.parentKind, parentGroupId: group.parentGroupId ?? null, })); return collection; diff --git a/src/main.js b/src/main.js index 33373b0..74b6aa3 100644 --- a/src/main.js +++ b/src/main.js @@ -1,4 +1,5 @@ import { yardGeoJSON } from "./data/yard.js"; +import { CheckboxTree } from "./lib/checkbox-tree/checkbox-tree.js"; import { resolveFeatureLinks, resolveFeatureStyle } from "./lib/feature-types.js"; const TILE_MAX_NATIVE_ZOOM = 19; @@ -46,10 +47,11 @@ L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", { }).addTo(map); let selectedTimelineDate = TIMELINE_PRESETS.at(-1).date; +let selectedFeatureIndexes = new Set(yardGeoJSON.features.map((_, index) => index)); let yardLayer = null; const timelineButtons = new Map(); -const legend = document.getElementById("legend"); +initializeVisibilityTree(); renderYardForDate(selectedTimelineDate); addTimelineControl(); map.fitBounds(getGeoJSONBounds(yardGeoJSON).pad(0.2), { maxZoom: MAP_MAX_ZOOM }); @@ -106,7 +108,7 @@ function createYardLayer(collection) { } function renderYardForDate(date) { - const filteredYard = filterFeatureCollectionByDate(yardGeoJSON, date); + const filteredYard = filterFeatureCollection(yardGeoJSON, date, selectedFeatureIndexes); if (yardLayer) { yardLayer.remove(); @@ -114,18 +116,179 @@ function renderYardForDate(date) { meterScaledIconMarkers.length = 0; yardLayer = createYardLayer(filteredYard).addTo(map); - renderLegend(filteredYard.features); syncTimelineButtons(); updateMeterScaledIcons(); } -function filterFeatureCollectionByDate(collection, date) { +function filterFeatureCollection(collection, date, visibleIndexes) { return { ...collection, - features: collection.features.filter((feature) => featureExistsOnDate(feature, date)), + features: collection.features.filter( + (feature, index) => visibleIndexes.has(index) && featureExistsOnDate(feature, date), + ), }; } +function initializeVisibilityTree() { + const tree = new CheckboxTree(document.getElementById("visibility-tree"), { + initiallyCollapsed: true, + initiallySelected: true, + storageKey: "a-plot-of-plants:visibility:v1", + onSelectionChange: ({ selectedNodes }) => { + selectedFeatureIndexes = getSelectedFeatureIndexes(selectedNodes); + renderYardForDate(selectedTimelineDate); + }, + }); + + tree.setData(buildVisibilityNodes(yardGeoJSON)); + selectedFeatureIndexes = getSelectedFeatureIndexes(tree.getSelectedNodes()); +} + +function getSelectedFeatureIndexes(nodes) { + return new Set( + nodes + .map((node) => node.metadata?.featureIndex) + .filter((index) => Number.isInteger(index)), + ); +} + +function buildVisibilityNodes(collection) { + const typeSchemasByKind = new Map(collection.featureTypes.map((type) => [type.kind, type])); + const categoriesById = new Map( + collection.featureCategories.map((category) => [ + category.id, + { + id: `visibility:category:${category.id}`, + label: category.label, + order: category.order, + children: [], + }, + ]), + ); + const featureEntries = collection.features.map((feature, featureIndex) => ({ + feature, + featureIndex, + })); + const groupsById = new Map( + collection.groups.map((group) => [ + group.id, + { + id: `visibility:${group.id}`, + label: group.label, + children: [], + parentGroupId: group.parentGroupId, + parentKind: group.parentKind, + categoryId: group.categoryId, + }, + ]), + ); + const groupedFeatureIndexes = new Set(); + + for (const entry of featureEntries) { + const group = entry.feature.properties.groupIds.map((id) => groupsById.get(id)).find(Boolean); + if (!group) { + continue; + } + const categoryId = typeSchemasByKind.get(entry.feature.properties.kind)?.categoryId; + if (group.categoryId && group.categoryId !== categoryId) { + throw new Error(`Visibility group spans multiple feature categories: ${group.label}`); + } + group.categoryId = categoryId; + group.children.push(toVisibilityLeaf(entry)); + groupedFeatureIndexes.add(entry.featureIndex); + } + + for (const group of groupsById.values()) { + const parent = groupsById.get(group.parentGroupId); + if (parent) { + if (parent.categoryId !== group.categoryId) { + throw new Error(`Nested visibility groups must share a category: ${group.label}`); + } + parent.children.push(group); + } + } + const rootGroups = [...groupsById.values()].filter( + (group) => !groupsById.has(group.parentGroupId) && group.children.length > 0, + ); + + const featuresByKind = new Map(); + for (const entry of featureEntries) { + if (groupedFeatureIndexes.has(entry.featureIndex)) { + continue; + } + const { kind } = entry.feature.properties; + const typeSchema = typeSchemasByKind.get(kind); + const categoryId = typeSchema?.categoryId; + const typeNode = featuresByKind.get(kind) ?? { + id: `visibility:type:${kind}`, + label: typeSchema?.collectionLabel ?? entry.feature.properties.label, + kind, + parentKind: typeSchema?.parentKind, + categoryId, + children: [], + }; + typeNode.children.push(toVisibilityLeaf(entry)); + featuresByKind.set(kind, typeNode); + } + + for (const typeNode of featuresByKind.values()) { + const parentTypeNode = featuresByKind.get(typeNode.parentKind); + if (parentTypeNode) { + parentTypeNode.children.push(typeNode); + } else { + categoriesById.get(typeNode.categoryId)?.children.push(typeNode); + } + } + + for (const group of rootGroups) { + const parentTypeNode = featuresByKind.get(group.parentKind); + if (parentTypeNode) { + parentTypeNode.children.push(group); + } else { + categoriesById.get(group.categoryId)?.children.push(group); + } + } + + for (const typeNode of featuresByKind.values()) { + delete typeNode.kind; + delete typeNode.parentKind; + delete typeNode.categoryId; + } + for (const group of groupsById.values()) { + delete group.parentGroupId; + delete group.parentKind; + delete group.categoryId; + } + + return [...categoriesById.values()] + .filter((category) => category.children.length > 0) + .sort((left, right) => left.order - right.order) + .map((category) => { + delete category.order; + return category; + }) + .map(sortVisibilityNode); +} + +function toVisibilityLeaf({ feature, featureIndex }) { + return { + id: `visibility:feature:${featureIndex}`, + label: feature.properties.name, + metadata: { featureIndex }, + }; +} + +function sortVisibilityNode(node) { + if (node.children) { + node.children = node.children.map(sortVisibilityNode).sort(compareVisibilityNodes); + } + return node; +} + +function compareVisibilityNodes(left, right) { + return left.label.localeCompare(right.label, undefined, { numeric: true }); +} + function featureExistsOnDate(feature, date) { const { plantedDate, removedDate } = feature.properties; return (!plantedDate || plantedDate <= date) && (!removedDate || date <= removedDate); @@ -182,46 +345,6 @@ function syncTimelineButtons() { } } -function renderLegend(features) { - const legendEntries = summarizeTypes(features); - - legend.replaceChildren( - ...legendEntries.map((entry) => { - const row = document.createElement("div"); - row.className = "legend-item"; - - const swatch = document.createElement("span"); - swatch.className = "legend-swatch"; - swatch.style.background = entry.color; - - const label = document.createElement("span"); - label.textContent = `${entry.label} (${entry.count})`; - - row.append(swatch, label); - return row; - }), - ); -} - -function summarizeTypes(features) { - const summary = new Map(); - - for (const feature of features) { - const key = feature.properties.kind; - const style = getStyle(feature); - const existing = summary.get(key) ?? { - label: feature.properties.label, - count: 0, - color: style.color ?? "#333333", - }; - - existing.count += 1; - summary.set(key, existing); - } - - return [...summary.values()]; -} - function getStyle(feature) { return resolveFeatureStyle(feature.properties.kind, feature.properties.details); } diff --git a/styles.css b/styles.css index bf8917d..48fad30 100644 --- a/styles.css +++ b/styles.css @@ -67,25 +67,25 @@ h1 { line-height: 1.5; } -.legend { - display: grid; - gap: 0.65rem; - margin: 1.5rem 0; +.visibility-panel { + margin-top: 1.5rem; } -.legend-item { - display: flex; - align-items: center; - gap: 0.75rem; - font-size: 0.95rem; +.visibility-panel h2 { + margin: 0 0 0.65rem; + color: var(--ink); + font-size: 1rem; } -.legend-swatch { - width: 0.9rem; - height: 0.9rem; - border-radius: 999px; - flex: none; - border: 1px solid rgba(0, 0, 0, 0.12); +#visibility-tree { + --checkbox-tree-indent: 16px; + --checkbox-tree-hover-color: var(--accent); + max-height: min(45vh, 28rem); + overflow: auto; + padding: 0.65rem; + border: 1px solid var(--panel-border); + border-radius: 12px; + background: rgba(255, 253, 246, 0.62); } .notes h2 { @@ -300,23 +300,6 @@ main { margin-bottom: 0; } - .legend { - grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr)); - gap: 0.5rem 0.75rem; - margin: 1rem 0 0; - } - - .legend-item { - gap: 0.5rem; - min-width: 0; - font-size: 0.9rem; - } - - .legend-item span:last-child { - min-width: 0; - overflow-wrap: anywhere; - } - #map { min-height: min(72svh, 46rem); border-radius: 12px; From 1372bbc0744b5374ffdf9e688ed00cd45b8de50f Mon Sep 17 00:00:00 2001 From: aaronaxvig Date: Sat, 11 Jul 2026 21:14:14 -0500 Subject: [PATCH 19/23] Fix arrow sizing --- src/lib/checkbox-tree/checkbox-tree.css | 4 ++++ src/lib/checkbox-tree/checkbox-tree.js | 1 - 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/lib/checkbox-tree/checkbox-tree.css b/src/lib/checkbox-tree/checkbox-tree.css index 08a5842..4e5bf8f 100644 --- a/src/lib/checkbox-tree/checkbox-tree.css +++ b/src/lib/checkbox-tree/checkbox-tree.css @@ -36,6 +36,10 @@ font: inherit; } +.checkbox-tree__toggle[aria-expanded="true"] { + transform: rotate(90deg); +} + .checkbox-tree__toggle:focus-visible { outline: 2px solid currentColor; outline-offset: 1px; diff --git a/src/lib/checkbox-tree/checkbox-tree.js b/src/lib/checkbox-tree/checkbox-tree.js index 837f210..c470d48 100644 --- a/src/lib/checkbox-tree/checkbox-tree.js +++ b/src/lib/checkbox-tree/checkbox-tree.js @@ -201,7 +201,6 @@ export class CheckboxTree { toggle.setAttribute('aria-expanded', String(expanded)); const label = this.nodesById.get(item.dataset.nodeId)?.label ?? 'branch'; toggle.setAttribute('aria-label', `${expanded ? 'Collapse' : 'Expand'} ${label}`); - toggle.textContent = expanded ? 'â–¼' : 'â–¶'; } directChildrenContainer(item) { From 9f8ce9b2154488eb775b6c593b2ac2bb6f4b96ae Mon Sep 17 00:00:00 2001 From: aaronaxvig Date: Sat, 11 Jul 2026 21:28:36 -0500 Subject: [PATCH 20/23] Use checkbox-tree library --- index.html | 2 +- src/main.js | 2 +- vendor/checkbox-tree/README.md | 5 +++++ {src/lib => vendor}/checkbox-tree/checkbox-tree.css | 0 {src/lib => vendor}/checkbox-tree/checkbox-tree.js | 0 5 files changed, 7 insertions(+), 2 deletions(-) create mode 100644 vendor/checkbox-tree/README.md rename {src/lib => vendor}/checkbox-tree/checkbox-tree.css (100%) rename {src/lib => vendor}/checkbox-tree/checkbox-tree.js (100%) diff --git a/index.html b/index.html index ec7816c..08f13ac 100644 --- a/index.html +++ b/index.html @@ -5,7 +5,7 @@ A Plot of Plants - + diff --git a/src/main.js b/src/main.js index 74b6aa3..7ecd99d 100644 --- a/src/main.js +++ b/src/main.js @@ -1,5 +1,5 @@ import { yardGeoJSON } from "./data/yard.js"; -import { CheckboxTree } from "./lib/checkbox-tree/checkbox-tree.js"; +import { CheckboxTree } from "../vendor/checkbox-tree/checkbox-tree.js"; import { resolveFeatureLinks, resolveFeatureStyle } from "./lib/feature-types.js"; const TILE_MAX_NATIVE_ZOOM = 19; diff --git a/vendor/checkbox-tree/README.md b/vendor/checkbox-tree/README.md new file mode 100644 index 0000000..1b8b58f --- /dev/null +++ b/vendor/checkbox-tree/README.md @@ -0,0 +1,5 @@ +# Checkbox Tree runtime snapshot + +Vendored from `@aaronaxvig/checkbox-tree` version `0.1.0` while +`git.axvig.com` is unavailable. Do not edit these runtime files directly; update +the canonical `checkbox-tree` repository and refresh this snapshot. diff --git a/src/lib/checkbox-tree/checkbox-tree.css b/vendor/checkbox-tree/checkbox-tree.css similarity index 100% rename from src/lib/checkbox-tree/checkbox-tree.css rename to vendor/checkbox-tree/checkbox-tree.css diff --git a/src/lib/checkbox-tree/checkbox-tree.js b/vendor/checkbox-tree/checkbox-tree.js similarity index 100% rename from src/lib/checkbox-tree/checkbox-tree.js rename to vendor/checkbox-tree/checkbox-tree.js From 5f450de1c79f39fe9cab981053f9e11859a4ec47 Mon Sep 17 00:00:00 2001 From: aaronaxvig Date: Sat, 11 Jul 2026 22:50:54 -0500 Subject: [PATCH 21/23] Different tree state URL encoding --- src/data/visibility-tree-manifest.json | 142 +++++++++++ src/main.js | 33 ++- vendor/checkbox-tree/README.md | 128 +++++++++- vendor/checkbox-tree/checkbox-tree.js | 335 ++++++++++++++++++++++++- 4 files changed, 627 insertions(+), 11 deletions(-) create mode 100644 src/data/visibility-tree-manifest.json diff --git a/src/data/visibility-tree-manifest.json b/src/data/visibility-tree-manifest.json new file mode 100644 index 0000000..f7a2ae8 --- /dev/null +++ b/src/data/visibility-tree-manifest.json @@ -0,0 +1,142 @@ +{ + "schema": 1, + "nodes": [ + "visibility:category:plants", + "visibility:category:plants>visibility:type:flower", + "visibility:category:plants>visibility:type:flower>visibility:feature:83", + "visibility:category:plants>visibility:type:flower>visibility:feature:84", + "visibility:category:plants>visibility:type:flower>visibility:feature:85", + "visibility:category:plants>visibility:type:flower>visibility:feature:86", + "visibility:category:plants>visibility:type:flower>visibility:feature:87", + "visibility:category:plants>visibility:type:flower>visibility:feature:88", + "visibility:category:plants>visibility:type:flower>visibility:feature:89", + "visibility:category:plants>visibility:type:flower>visibility:feature:90", + "visibility:category:plants>visibility:type:flower>visibility:feature:91", + "visibility:category:plants>visibility:type:flower>visibility:type:daylily", + "visibility:category:plants>visibility:type:flower>visibility:type:daylily>visibility:feature:53", + "visibility:category:plants>visibility:type:flower>visibility:type:daylily>visibility:feature:54", + "visibility:category:plants>visibility:type:flower>visibility:type:daylily>visibility:feature:55", + "visibility:category:plants>visibility:type:flower>visibility:type:daylily>visibility:feature:56", + "visibility:category:plants>visibility:type:flower>visibility:type:daylily>visibility:feature:57", + "visibility:category:plants>visibility:type:flower>visibility:type:daylily>visibility:feature:58", + "visibility:category:plants>visibility:type:flower>visibility:type:daylily>visibility:feature:59", + "visibility:category:plants>visibility:type:flower>visibility:type:daylily>visibility:feature:60", + "visibility:category:plants>visibility:type:flower>visibility:type:daylily>visibility:feature:61", + "visibility:category:plants>visibility:type:flower>visibility:type:daylily>visibility:feature:62", + "visibility:category:plants>visibility:type:flower>visibility:type:daylily>visibility:feature:63", + "visibility:category:plants>visibility:type:flower>visibility:type:daylily>visibility:feature:64", + "visibility:category:plants>visibility:type:flower>visibility:type:daylily>visibility:feature:65", + "visibility:category:plants>visibility:type:flower>visibility:type:daylily>visibility:feature:66", + "visibility:category:plants>visibility:type:flower>visibility:type:daylily>visibility:feature:67", + "visibility:category:plants>visibility:type:flower>visibility:type:daylily>visibility:feature:68", + "visibility:category:plants>visibility:type:flower>visibility:type:daylily>visibility:feature:69", + "visibility:category:plants>visibility:type:flower>visibility:type:daylily>visibility:feature:70", + "visibility:category:plants>visibility:type:flower>visibility:type:daylily>visibility:feature:71", + "visibility:category:plants>visibility:type:flower>visibility:type:daylily>visibility:feature:72", + "visibility:category:plants>visibility:type:flower>visibility:type:daylily>visibility:feature:73", + "visibility:category:plants>visibility:type:flower>visibility:type:daylily>visibility:feature:74", + "visibility:category:plants>visibility:type:flower>visibility:type:daylily>visibility:feature:75", + "visibility:category:plants>visibility:type:flower>visibility:type:daylily>visibility:feature:76", + "visibility:category:plants>visibility:type:flower>visibility:type:daylily>visibility:feature:77", + "visibility:category:plants>visibility:type:flower>visibility:type:daylily>visibility:feature:78", + "visibility:category:plants>visibility:type:flower>visibility:type:daylily>visibility:feature:79", + "visibility:category:plants>visibility:type:flower>visibility:type:daylily>visibility:feature:80", + "visibility:category:plants>visibility:type:flower>visibility:type:daylily>visibility:feature:81", + "visibility:category:plants>visibility:type:flower>visibility:type:daylily>visibility:feature:82", + "visibility:category:plants>visibility:type:tree", + "visibility:category:plants>visibility:type:tree>visibility:feature:92", + "visibility:category:plants>visibility:type:tree>visibility:feature:93", + "visibility:category:plants>visibility:type:tree>visibility:feature:94", + "visibility:category:plants>visibility:type:tree>visibility:feature:95", + "visibility:category:plants>visibility:type:tree>visibility:feature:96", + "visibility:category:plants>visibility:type:tree>visibility:feature:97", + "visibility:category:plants>visibility:type:tree>visibility:feature:98", + "visibility:category:plants>visibility:type:tree>visibility:feature:99", + "visibility:category:plants>visibility:type:tree>visibility:feature:100", + "visibility:category:plants>visibility:type:tree>visibility:feature:101", + "visibility:category:plants>visibility:type:tree>visibility:feature:102", + "visibility:category:plants>visibility:type:tree>visibility:feature:103", + "visibility:category:plants>visibility:type:tree>visibility:feature:114", + "visibility:category:plants>visibility:type:tree>visibility:feature:115", + "visibility:category:plants>visibility:type:tree>visibility:feature:116", + "visibility:category:plants>visibility:type:tree>visibility:feature:117", + "visibility:category:plants>visibility:type:bush", + "visibility:category:plants>visibility:type:bush>visibility:feature:109", + "visibility:category:plants>visibility:type:bush>visibility:feature:110", + "visibility:category:plants>visibility:type:bush>visibility:feature:111", + "visibility:category:plants>visibility:type:bush>visibility:feature:112", + "visibility:category:plants>visibility:type:bush>visibility:feature:113", + "visibility:category:plants>visibility:type:bush>visibility:group:juniper-trees", + "visibility:category:plants>visibility:type:bush>visibility:group:juniper-trees>visibility:feature:104", + "visibility:category:plants>visibility:type:bush>visibility:group:juniper-trees>visibility:feature:105", + "visibility:category:plants>visibility:type:bush>visibility:group:juniper-trees>visibility:feature:106", + "visibility:category:plants>visibility:type:bush>visibility:group:juniper-trees>visibility:feature:107", + "visibility:category:plants>visibility:type:bush>visibility:group:juniper-trees>visibility:feature:108", + "visibility:category:structures", + "visibility:category:structures>visibility:type:fence", + "visibility:category:structures>visibility:type:fence>visibility:feature:1", + "visibility:category:structures>visibility:type:flowerBed", + "visibility:category:structures>visibility:type:flowerBed>visibility:feature:48", + "visibility:category:structures>visibility:type:flowerBed>visibility:feature:49", + "visibility:category:structures>visibility:type:flowerBed>visibility:feature:50", + "visibility:category:structures>visibility:type:flowerBed>visibility:feature:51", + "visibility:category:structures>visibility:type:flowerBed>visibility:feature:52", + "visibility:category:structures>visibility:group:buildings", + "visibility:category:structures>visibility:group:buildings>visibility:feature:40", + "visibility:category:structures>visibility:group:buildings>visibility:feature:41", + "visibility:category:structures>visibility:group:buildings>visibility:group:house-parts", + "visibility:category:structures>visibility:group:buildings>visibility:group:house-parts>visibility:feature:42", + "visibility:category:structures>visibility:group:buildings>visibility:group:house-parts>visibility:feature:43", + "visibility:category:structures>visibility:group:paved-surfaces", + "visibility:category:structures>visibility:group:paved-surfaces>visibility:feature:44", + "visibility:category:structures>visibility:group:paved-surfaces>visibility:feature:45", + "visibility:category:structures>visibility:group:paved-surfaces>visibility:feature:46", + "visibility:category:structures>visibility:group:paved-surfaces>visibility:feature:47", + "visibility:category:infrastructure", + "visibility:category:infrastructure>visibility:type:sprinkler", + "visibility:category:infrastructure>visibility:type:sprinkler>visibility:feature:118", + "visibility:category:infrastructure>visibility:type:sprinkler>visibility:feature:119", + "visibility:category:reference", + "visibility:category:reference>visibility:type:lotBoundary", + "visibility:category:reference>visibility:type:lotBoundary>visibility:feature:0", + "visibility:category:reference>visibility:group:yard-grid", + "visibility:category:reference>visibility:group:yard-grid>visibility:feature:2", + "visibility:category:reference>visibility:group:yard-grid>visibility:feature:3", + "visibility:category:reference>visibility:group:yard-grid>visibility:feature:4", + "visibility:category:reference>visibility:group:yard-grid>visibility:feature:5", + "visibility:category:reference>visibility:group:yard-grid>visibility:feature:6", + "visibility:category:reference>visibility:group:yard-grid>visibility:feature:7", + "visibility:category:reference>visibility:group:yard-grid>visibility:feature:8", + "visibility:category:reference>visibility:group:yard-grid>visibility:feature:9", + "visibility:category:reference>visibility:group:yard-grid>visibility:feature:10", + "visibility:category:reference>visibility:group:yard-grid>visibility:feature:11", + "visibility:category:reference>visibility:group:yard-grid>visibility:feature:12", + "visibility:category:reference>visibility:group:yard-grid>visibility:feature:13", + "visibility:category:reference>visibility:group:yard-grid>visibility:feature:14", + "visibility:category:reference>visibility:group:yard-grid>visibility:feature:15", + "visibility:category:reference>visibility:group:yard-grid>visibility:feature:16", + "visibility:category:reference>visibility:group:yard-grid>visibility:feature:17", + "visibility:category:reference>visibility:group:yard-grid>visibility:feature:18", + "visibility:category:reference>visibility:group:yard-grid>visibility:feature:19", + "visibility:category:reference>visibility:group:yard-grid>visibility:feature:20", + "visibility:category:reference>visibility:group:yard-grid>visibility:feature:21", + "visibility:category:reference>visibility:group:yard-grid>visibility:feature:22", + "visibility:category:reference>visibility:group:yard-grid>visibility:feature:23", + "visibility:category:reference>visibility:group:yard-grid>visibility:feature:24", + "visibility:category:reference>visibility:group:yard-grid>visibility:feature:25", + "visibility:category:reference>visibility:group:yard-grid>visibility:feature:26", + "visibility:category:reference>visibility:group:yard-grid>visibility:feature:27", + "visibility:category:reference>visibility:group:yard-grid>visibility:feature:28", + "visibility:category:reference>visibility:group:yard-grid>visibility:feature:29", + "visibility:category:reference>visibility:group:yard-grid>visibility:feature:30", + "visibility:category:reference>visibility:group:yard-grid>visibility:feature:31", + "visibility:category:reference>visibility:group:yard-grid>visibility:feature:32", + "visibility:category:reference>visibility:group:yard-grid>visibility:feature:33", + "visibility:category:reference>visibility:group:yard-grid>visibility:feature:34", + "visibility:category:reference>visibility:group:yard-grid>visibility:feature:35", + "visibility:category:reference>visibility:group:yard-grid>visibility:feature:36", + "visibility:category:reference>visibility:group:yard-grid>visibility:feature:37", + "visibility:category:reference>visibility:group:yard-grid>visibility:feature:38", + "visibility:category:reference>visibility:group:yard-grid>visibility:feature:39" + ] +} diff --git a/src/main.js b/src/main.js index 7ecd99d..5eb0ac2 100644 --- a/src/main.js +++ b/src/main.js @@ -1,5 +1,5 @@ import { yardGeoJSON } from "./data/yard.js"; -import { CheckboxTree } from "../vendor/checkbox-tree/checkbox-tree.js"; +import { CheckboxTree, loadManifest } from "../vendor/checkbox-tree/checkbox-tree.js"; import { resolveFeatureLinks, resolveFeatureStyle } from "./lib/feature-types.js"; const TILE_MAX_NATIVE_ZOOM = 19; @@ -51,7 +51,7 @@ let selectedFeatureIndexes = new Set(yardGeoJSON.features.map((_, index) => inde let yardLayer = null; const timelineButtons = new Map(); -initializeVisibilityTree(); +await initializeVisibilityTree(); renderYardForDate(selectedTimelineDate); addTimelineControl(); map.fitBounds(getGeoJSONBounds(yardGeoJSON).pad(0.2), { maxZoom: MAP_MAX_ZOOM }); @@ -129,19 +129,44 @@ function filterFeatureCollection(collection, date, visibleIndexes) { }; } -function initializeVisibilityTree() { - const tree = new CheckboxTree(document.getElementById("visibility-tree"), { +async function initializeVisibilityTree() { + const container = document.getElementById("visibility-tree"); + const manifest = loadManifest( + await fetch("./src/data/visibility-tree-manifest.json").then((response) => response.json()), + ); + const tree = new CheckboxTree(container, { initiallyCollapsed: true, initiallySelected: true, storageKey: "a-plot-of-plants:visibility:v1", + manifest, onSelectionChange: ({ selectedNodes }) => { selectedFeatureIndexes = getSelectedFeatureIndexes(selectedNodes); renderYardForDate(selectedTimelineDate); + pushTreeState(tree); }, }); tree.setData(buildVisibilityNodes(yardGeoJSON)); selectedFeatureIndexes = getSelectedFeatureIndexes(tree.getSelectedNodes()); + + container.addEventListener("click", (event) => { + if (event.target.closest(".checkbox-tree__toggle")) { + pushTreeState(tree); + } + }); + window.addEventListener("popstate", () => { + tree.restoreStateFromLocation(); + selectedFeatureIndexes = getSelectedFeatureIndexes(tree.getSelectedNodes()); + renderYardForDate(selectedTimelineDate); + }); +} + +function pushTreeState(tree) { + const url = new URL(window.location.href); + url.hash = tree.serializeStateToFragment(); + if (url.href !== window.location.href) { + history.pushState(null, "", url); + } } function getSelectedFeatureIndexes(nodes) { diff --git a/vendor/checkbox-tree/README.md b/vendor/checkbox-tree/README.md index 1b8b58f..a14d6f3 100644 --- a/vendor/checkbox-tree/README.md +++ b/vendor/checkbox-tree/README.md @@ -1,5 +1,125 @@ -# Checkbox Tree runtime snapshot +# Checkbox Tree -Vendored from `@aaronaxvig/checkbox-tree` version `0.1.0` while -`git.axvig.com` is unavailable. Do not edit these runtime files directly; update -the canonical `checkbox-tree` repository and refresh this snapshot. +A dependency-free, instance-based checkbox tree for browser ES modules. It +supports cascading selection, indeterminate parent states, expandable branches, +multiple independent instances, optional local-storage persistence, and compact +shareable URL state backed by an append-only node manifest. + +## Install + +```sh +npm install @aaronaxvig/checkbox-tree +``` + +The package is not published yet. During local development, install it by path: + +```sh +npm install ../checkbox-tree +``` + +## Usage + +```js +import { CheckboxTree } from "@aaronaxvig/checkbox-tree"; +import "@aaronaxvig/checkbox-tree/styles.css"; + +const tree = new CheckboxTree(document.querySelector("#example-tree"), { + initiallySelected: true, + storageKey: "example-tree-state", + onSelectionChange({ selectedIds, selectedNodes }) { + console.log(selectedIds, selectedNodes); + }, +}); + +tree.setData([ + { + id: "fruit", + label: "Fruit", + children: [ + { id: "apple", label: "Apple", metadata: { color: "red" } }, + { id: "pear", label: "Pear", metadata: { color: "green" } }, + ], + }, +]); +``` + +For applications without a bundler, serve or copy the two files in `src/` and +use relative URLs: + +```html + + +``` + +Every node requires unique string `id` and `label` properties. Nodes may also +have `children`, application-owned `metadata`, and `selectable` or `disabled` +flags. + +## API + +- `new CheckboxTree(container, options)` creates an independent instance. +- `setData(nodes)` validates and renders nodes, then restores saved state. +- `getSelectedIds()` returns selected node IDs. +- `getSelectedNodes()` returns selected source node objects. +- `setSelectedIds(ids, { notify })` replaces the selection. +- `restoreState()` restores selection and expansion state. +- `destroy()` removes listeners, markup, and the root CSS class. +- `serializeStateToFragment(manifest)` returns `#v=1&c=...&x=...` state. +- `restoreStateFromLocation(manifest, location)` safely applies URL state. +- `validateManifest(manifest)` reports unregistered and missing paths. + +Options: + +- `initiallyCollapsed` defaults to `true`. +- `initiallySelected` defaults to `false`. +- `storageKey` defaults to `null`, which disables persistence. +- `onSelectionChange` receives `{ selectedIds, selectedNodes }`. +- `manifest` optionally supplies a parsed manifest and automatically restores + state from `window.location.hash` after `setData()`. + +## Shareable URL state + +Create a JSON manifest whose array positions are permanent slots. Canonical node +paths join ancestor IDs with `>`: + +```json +{ "schema": 1, "nodes": ["fruit", "fruit>apple", "fruit>pear", null] } +``` + +Import `loadManifest`, pass its result as the `manifest` option, and call +`tree.serializeStateToFragment()` when creating a share link. Existing entries +must never be reordered. Append new paths, replace removed paths with `null`, and +edit a path in place for a logical rename or move. Node IDs therefore cannot +contain `>`. + +The fragment adaptively encodes checked (`c`) and expanded (`x`) state using the +shortest of a dense bitset, sparse enabled slots, or sparse disabled slots. +Sparse slot numbers are delta-encoded variable-length integers. A coverage value +in the disabled-slot form ensures nodes appended later still default to unchecked +and collapsed. Malformed or unsupported state is ignored. +The helpers `encodeTreeState`, `decodeTreeState`, `applyTreeState`, and +`restoreStateFromLocation` are also exported for custom integrations. +Use `validateManifestEvolution(previous, next)` in a build check to reject +removed slots and tombstone reuse; path changes are reported for deliberate +rename/move review. + +Styling is namespaced under `.checkbox-tree`. Override the custom properties +`--checkbox-tree-indent`, `--checkbox-tree-toggle-size`, and +`--checkbox-tree-hover-color` in the consuming application. + +## Development + +```sh +npm install +npm test +npm run pack:check +``` + +## Future demo ideas + +- Add population metadata to the country, state, and county nodes and display the + total population represented by the current selection. Define the aggregation + rule carefully so checked ancestors and their checked descendants are not + counted twice; one option is to total only the most specific checked nodes. diff --git a/vendor/checkbox-tree/checkbox-tree.js b/vendor/checkbox-tree/checkbox-tree.js index c470d48..4974fc6 100644 --- a/vendor/checkbox-tree/checkbox-tree.js +++ b/vendor/checkbox-tree/checkbox-tree.js @@ -2,9 +2,310 @@ const DEFAULT_OPTIONS = { initiallyCollapsed: true, initiallySelected: false, storageKey: null, - onSelectionChange: null + onSelectionChange: null, + manifest: null }; +const SERIALIZATION_VERSION = 1; +const PATH_DELIMITER = '>'; + +export function loadManifest(source) { + let records; + let schema = 1; + + if (typeof source === 'string') { + records = source.split(/\r?\n/) + .map(line => line.trim()) + .filter(line => line && !line.startsWith('#')) + .map(line => line === '!deleted' ? null : line); + } else if (source && typeof source === 'object' && Array.isArray(source.nodes)) { + schema = source.schema; + records = source.nodes; + } else if (source && Array.isArray(source.slots)) { + return normalizeManifest(source.schema, source.slots.map(slot => slot?.deleted ? null : slot?.path)); + } else { + throw new TypeError('CheckboxTree manifest must be text or an object with a nodes array.'); + } + + return normalizeManifest(schema, records); +} + +function normalizeManifest(schema, records) { + if (schema !== 1) { + throw new Error(`Unsupported CheckboxTree manifest schema: ${schema}`); + } + + const pathToSlot = new Map(); + const slots = records.map((record, index) => { + if (record === null || record === '!deleted') { + return { path: null, deleted: true }; + } + if (typeof record !== 'string' || !record || record.trim() !== record) { + throw new TypeError(`Invalid CheckboxTree manifest entry at slot ${index}.`); + } + if (pathToSlot.has(record)) { + throw new Error(`Duplicate CheckboxTree manifest path: ${record}`); + } + pathToSlot.set(record, index); + return { path: record, deleted: false }; + }); + + return { schema: 1, slots, pathToSlot }; +} + +export function setBit(bytes, bitIndex, value) { + const byteIndex = Math.floor(bitIndex / 8); + const mask = 1 << (bitIndex % 8); + if (value) bytes[byteIndex] |= mask; + else bytes[byteIndex] &= ~mask; +} + +export function getBit(bytes, bitIndex) { + const byteIndex = Math.floor(bitIndex / 8); + return byteIndex < bytes.length && (bytes[byteIndex] & (1 << (bitIndex % 8))) !== 0; +} + +export function bytesToBase64Url(bytes) { + let binary = ''; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); +} + +export function base64UrlToBytes(value, maximumLength = Infinity) { + if (typeof value !== 'string' || !/^[A-Za-z0-9_-]*$/.test(value) || value.length % 4 === 1) { + throw new Error('Invalid Base64URL value.'); + } + if (Number.isFinite(maximumLength) && value.length > Math.ceil(maximumLength * 4 / 3) + 2) { + throw new Error('Encoded tree state exceeds the manifest size.'); + } + const base64 = value.replace(/-/g, '+').replace(/_/g, '/'); + const binary = atob(base64 + '='.repeat((4 - base64.length % 4) % 4)); + if (binary.length > maximumLength) throw new Error('Encoded tree state exceeds the manifest size.'); + return Uint8Array.from(binary, character => character.charCodeAt(0)); +} + +function trimTrailingZeroBytes(bytes) { + let end = bytes.length; + while (end && bytes[end - 1] === 0) end--; + return bytes.slice(0, end); +} + +function encodeUnsignedVarint(value) { + const bytes = []; + do { + let byte = value & 0x7f; + value = Math.floor(value / 128); + if (value) byte |= 0x80; + bytes.push(byte); + } while (value); + return bytes; +} + +function decodeUnsignedVarint(bytes, offset) { + let value = 0; + let multiplier = 1; + for (let index = offset; index < bytes.length && index < offset + 8; index++) { + const byte = bytes[index]; + value += (byte & 0x7f) * multiplier; + if ((byte & 0x80) === 0) return { value, offset: index + 1 }; + multiplier *= 128; + if (!Number.isSafeInteger(value) || !Number.isSafeInteger(multiplier)) break; + } + throw new Error('Invalid adaptive tree-state integer.'); +} + +function encodeSparseIndexes(indexes) { + const bytes = []; + let previous = -1; + for (const index of indexes) { + bytes.push(...encodeUnsignedVarint(index - previous - 1)); + previous = index; + } + return bytes; +} + +function decodeSparseIndexes(bytes, offset, limit) { + const indexes = new Set(); + let previous = -1; + while (offset < bytes.length) { + const decoded = decodeUnsignedVarint(bytes, offset); + const index = previous + decoded.value + 1; + if (index >= limit) throw new Error('Sparse tree state contains an out-of-range slot.'); + indexes.add(index); + previous = index; + offset = decoded.offset; + } + return indexes; +} + +export function encodeAdaptiveBitset(values, slotCount) { + const enabled = []; + const disabled = []; + const denseBytes = new Uint8Array(Math.ceil(slotCount / 8)); + for (const [slot, value] of values) { + (value ? enabled : disabled).push(slot); + setBit(denseBytes, slot, value); + } + enabled.sort((left, right) => left - right); + disabled.sort((left, right) => left - right); + if (enabled.length === 0) return new Uint8Array(); + + const candidates = [ + Uint8Array.from([0, ...trimTrailingZeroBytes(denseBytes)]), + Uint8Array.from([1, ...encodeSparseIndexes(enabled)]), + Uint8Array.from([2, ...encodeUnsignedVarint(slotCount), ...encodeSparseIndexes(disabled)]) + ]; + return candidates.reduce((shortest, candidate) => + candidate.length < shortest.length ? candidate : shortest + ); +} + +export function decodeAdaptiveBitset(bytes, slotCount) { + if (bytes.length === 0) return () => false; + const mode = bytes[0]; + if (mode === 0) { + const dense = bytes.slice(1); + if (dense.length > Math.ceil(slotCount / 8)) { + throw new Error('Dense tree state exceeds the manifest size.'); + } + return slot => getBit(dense, slot); + } + if (mode === 1) { + const enabled = decodeSparseIndexes(bytes, 1, slotCount); + return slot => enabled.has(slot); + } + if (mode === 2) { + const coverage = decodeUnsignedVarint(bytes, 1); + if (coverage.value > slotCount) throw new Error('Tree-state coverage exceeds the manifest size.'); + const disabled = decodeSparseIndexes(bytes, coverage.offset, coverage.value); + return slot => slot < coverage.value && !disabled.has(slot); + } + throw new Error('Unknown adaptive tree-state mode.'); +} + +function asManifest(manifest) { + return manifest?.pathToSlot instanceof Map ? manifest : loadManifest(manifest); +} + +export function validateManifest(manifest, tree) { + const normalized = asManifest(manifest); + const treePaths = new Set(tree.nodesByPath.keys()); + return { + valid: Array.from(treePaths).every(path => normalized.pathToSlot.has(path)), + unregisteredPaths: Array.from(treePaths).filter(path => !normalized.pathToSlot.has(path)), + missingPaths: normalized.slots + .filter(slot => !slot.deleted && !treePaths.has(slot.path)) + .map(slot => slot.path) + }; +} + +export function validateManifestEvolution(previous, next) { + const oldManifest = asManifest(previous); + const newManifest = asManifest(next); + const errors = []; + + if (newManifest.slots.length < oldManifest.slots.length) { + errors.push('Manifest slots cannot be removed; use tombstones.'); + } + oldManifest.slots.forEach((oldSlot, index) => { + const newSlot = newManifest.slots[index]; + if (!newSlot) return; + if (oldSlot.deleted && !newSlot.deleted) { + errors.push(`Tombstoned slot ${index} cannot be reused.`); + } + if (!oldSlot.deleted && !newSlot.deleted && oldSlot.path !== newSlot.path) { + errors.push(`Slot ${index} changed path; confirm this is a logical rename or move.`); + } + }); + + return { valid: errors.length === 0, errors }; +} + +export function encodeTreeState(tree, manifest) { + const normalized = asManifest(manifest); + const checkedValues = new Map(); + const expandedValues = new Map(); + + for (const [path, node] of tree.nodesByPath) { + const slot = normalized.pathToSlot.get(path); + if (slot === undefined) continue; + const item = tree.itemForNode(node.id); + const checkbox = item && tree.directCheckbox(item); + const toggle = item?.querySelector(':scope > .checkbox-tree__row > .checkbox-tree__toggle'); + checkedValues.set(slot, Boolean(checkbox?.checked)); + expandedValues.set(slot, toggle?.getAttribute('aria-expanded') === 'true'); + } + + const checkedValue = bytesToBase64Url(encodeAdaptiveBitset(checkedValues, normalized.slots.length)); + const expandedValue = bytesToBase64Url(encodeAdaptiveBitset(expandedValues, normalized.slots.length)); + return { version: SERIALIZATION_VERSION, checked: checkedValue || null, expanded: expandedValue || null }; +} + +export function decodeTreeState(fragment, manifest) { + const normalized = asManifest(manifest); + const value = fragment.startsWith('#') ? fragment.slice(1) : fragment; + const params = new URLSearchParams(value); + if (params.get('v') !== String(SERIALIZATION_VERSION)) { + return { applied: false, version: params.get('v'), warnings: ['Missing or unsupported serialization version.'] }; + } + const maximumLength = Math.max( + Math.ceil(normalized.slots.length / 8) + 1, + normalized.slots.length * 2 + 10 + ); + try { + const checked = base64UrlToBytes(params.get('c') ?? '', maximumLength); + const expanded = base64UrlToBytes(params.get('x') ?? '', maximumLength); + decodeAdaptiveBitset(checked, normalized.slots.length); + decodeAdaptiveBitset(expanded, normalized.slots.length); + return { + applied: true, + version: SERIALIZATION_VERSION, + checked, + expanded, + warnings: [] + }; + } catch (error) { + return { applied: false, version: SERIALIZATION_VERSION, warnings: [error.message] }; + } +} + +export function applyTreeState(tree, manifest, state) { + if (!state?.applied) return false; + const normalized = asManifest(manifest); + let isChecked; + let isExpanded; + try { + isChecked = decodeAdaptiveBitset(state.checked, normalized.slots.length); + isExpanded = decodeAdaptiveBitset(state.expanded, normalized.slots.length); + } catch { + return false; + } + for (const [path, node] of tree.nodesByPath) { + const slot = normalized.pathToSlot.get(path); + if (slot === undefined) continue; + const item = tree.itemForNode(node.id); + const checkbox = item && tree.directCheckbox(item); + if (checkbox && !checkbox.disabled) checkbox.checked = isChecked(slot); + tree.setExpanded(item, isExpanded(slot)); + } + tree.updateAllParentCheckboxes(); + return true; +} + +export function serializeStateToFragment(tree, manifest) { + const state = encodeTreeState(tree, manifest); + const params = new URLSearchParams({ v: String(state.version) }); + if (state.checked) params.set('c', state.checked); + if (state.expanded) params.set('x', state.expanded); + return `#${params}`; +} + +export function restoreStateFromLocation(tree, manifest, location = window.location) { + const state = decodeTreeState(location.hash ?? '', manifest); + if (state.applied) applyTreeState(tree, manifest, state); + return state; +} + export class CheckboxTree { constructor(container, options = {}) { if (!(container instanceof Element)) { @@ -14,6 +315,7 @@ export class CheckboxTree { this.container = container; this.options = { ...DEFAULT_OPTIONS, ...options }; this.nodesById = new Map(); + this.nodesByPath = new Map(); this.rootNodes = []; this.handleChange = this.handleChange.bind(this); this.handleClick = this.handleClick.bind(this); @@ -29,11 +331,13 @@ export class CheckboxTree { } this.nodesById.clear(); + this.nodesByPath.clear(); this.rootNodes = nodes; this.indexNodes(nodes); this.render(); this.updateAllParentCheckboxes(); this.restoreState(); + if (this.options.manifest) this.restoreStateFromLocation(); } getSelectedIds() { @@ -66,9 +370,10 @@ export class CheckboxTree { this.container.classList.remove('checkbox-tree'); this.container.replaceChildren(); this.nodesById.clear(); + this.nodesByPath.clear(); } - indexNodes(nodes) { + indexNodes(nodes, parentPath = '') { nodes.forEach(node => { if (!node || typeof node.id !== 'string' || !node.id || typeof node.label !== 'string') { throw new TypeError('Each CheckboxTree node requires non-empty string id and label properties.'); @@ -77,12 +382,18 @@ export class CheckboxTree { throw new Error(`Duplicate CheckboxTree node id: ${node.id}`); } + if (node.id.includes(PATH_DELIMITER)) { + throw new Error(`CheckboxTree node id cannot contain "${PATH_DELIMITER}": ${node.id}`); + } + this.nodesById.set(node.id, node); + const path = parentPath ? `${parentPath}${PATH_DELIMITER}${node.id}` : node.id; + this.nodesByPath.set(path, node); if (node.children !== undefined) { if (!Array.isArray(node.children)) { throw new TypeError(`CheckboxTree node children must be an array: ${node.id}`); } - this.indexNodes(node.children); + this.indexNodes(node.children, path); } }); } @@ -191,6 +502,7 @@ export class CheckboxTree { } setExpanded(item, expanded) { + if (!item) return; const toggle = item.querySelector(':scope > .checkbox-tree__row > .checkbox-tree__toggle'); const children = this.directChildrenContainer(item); if (!toggle || !children) { @@ -211,6 +523,11 @@ export class CheckboxTree { return item.querySelector(':scope > .checkbox-tree__row .checkbox-tree__checkbox'); } + itemForNode(id) { + return Array.from(this.container.querySelectorAll('.checkbox-tree__item')) + .find(item => item.dataset.nodeId === id) ?? null; + } + updateAncestorCheckboxes(item) { let parentItem = item.parentElement?.closest('.checkbox-tree__item'); while (parentItem && this.container.contains(parentItem)) { @@ -286,4 +603,16 @@ export class CheckboxTree { }); } } + + validateManifest(manifest = this.options.manifest) { + return validateManifest(manifest, this); + } + + serializeStateToFragment(manifest = this.options.manifest) { + return serializeStateToFragment(this, manifest); + } + + restoreStateFromLocation(manifest = this.options.manifest, location = window.location) { + return restoreStateFromLocation(this, manifest, location); + } } From 1d92cf4473decdc3b360147cc56db28b9968fd88 Mon Sep 17 00:00:00 2001 From: aaronaxvig Date: Sun, 12 Jul 2026 21:46:13 -0500 Subject: [PATCH 22/23] Revised checkbox-tree functions and related --- src/main.js | 1 + vendor/checkbox-tree/README.md | 16 +- vendor/checkbox-tree/checkbox-tree.js | 343 +++----------------------- vendor/checkbox-tree/state-codec.js | 206 ++++++++++++++++ vendor/checkbox-tree/tree-state.js | 310 +++++++++++++++++++++++ 5 files changed, 571 insertions(+), 305 deletions(-) create mode 100644 vendor/checkbox-tree/state-codec.js create mode 100644 vendor/checkbox-tree/tree-state.js diff --git a/src/main.js b/src/main.js index 5eb0ac2..733da45 100644 --- a/src/main.js +++ b/src/main.js @@ -139,6 +139,7 @@ async function initializeVisibilityTree() { initiallySelected: true, storageKey: "a-plot-of-plants:visibility:v1", manifest, + stateEncoding: "tiered", onSelectionChange: ({ selectedNodes }) => { selectedFeatureIndexes = getSelectedFeatureIndexes(selectedNodes); renderYardForDate(selectedTimelineDate); diff --git a/vendor/checkbox-tree/README.md b/vendor/checkbox-tree/README.md index a14d6f3..b8a8b67 100644 --- a/vendor/checkbox-tree/README.md +++ b/vendor/checkbox-tree/README.md @@ -43,8 +43,8 @@ tree.setData([ ]); ``` -For applications without a bundler, serve or copy the two files in `src/` and -use relative URLs: +For applications without a bundler, serve or copy the `src/` directory and use +relative URLs. The main module imports its state-codec modules alongside it: ```html @@ -78,6 +78,9 @@ Options: - `onSelectionChange` receives `{ selectedIds, selectedNodes }`. - `manifest` optionally supplies a parsed manifest and automatically restores state from `window.location.hash` after `setData()`. +- `stateEncoding` selects `"adaptive"` (the default), `"dense"`, or `"tiered"`. + The website's configured value is the URL-format contract and is not embedded + in the fragment. ## Shareable URL state @@ -105,6 +108,15 @@ Use `validateManifestEvolution(previous, next)` in a build check to reject removed slots and tombstone reuse; path changes are reported for deliberate rename/move review. +With `stateEncoding: "tiered"`, `c1` stores root checked state and each deeper +`cN` field stores nodes whose checked state differs from their parent. Expansion +does not inherit: every `xN` stores the actual expanded branches at that depth, +with collapsed as the baseline. Each tier field uses the adaptive codec. The +`n` field records manifest coverage so nodes appended later still default to +false. A non-selectable parent supplies a false checked baseline. With +`stateEncoding: "dense"`, `c` and `x` contain trimmed +least-significant-bit-first bitsets. + Styling is namespaced under `.checkbox-tree`. Override the custom properties `--checkbox-tree-indent`, `--checkbox-tree-toggle-size`, and `--checkbox-tree-hover-color` in the consuming application. diff --git a/vendor/checkbox-tree/checkbox-tree.js b/vendor/checkbox-tree/checkbox-tree.js index 4974fc6..0dbe2f9 100644 --- a/vendor/checkbox-tree/checkbox-tree.js +++ b/vendor/checkbox-tree/checkbox-tree.js @@ -1,311 +1,44 @@ +// Dependency-free checkbox tree widget. State serialization lives in focused sibling modules. +import { + PATH_DELIMITER, + assertStateEncoding +} from './state-codec.js'; +import { + restoreStateFromLocation, + serializeStateToFragment, + validateManifest +} from './tree-state.js'; + +export { + PATH_DELIMITER, + SERIALIZATION_VERSION, + base64UrlToBytes, + bytesToBase64Url, + decodeAdaptiveBitset, + encodeAdaptiveBitset, + getBit, + loadManifest, + setBit +} from './state-codec.js'; +export { + applyTreeState, + decodeTreeState, + encodeTreeState, + restoreStateFromLocation, + serializeStateToFragment, + validateManifest, + validateManifestEvolution +} from './tree-state.js'; + const DEFAULT_OPTIONS = { initiallyCollapsed: true, initiallySelected: false, storageKey: null, onSelectionChange: null, - manifest: null + manifest: null, + stateEncoding: 'adaptive' }; -const SERIALIZATION_VERSION = 1; -const PATH_DELIMITER = '>'; - -export function loadManifest(source) { - let records; - let schema = 1; - - if (typeof source === 'string') { - records = source.split(/\r?\n/) - .map(line => line.trim()) - .filter(line => line && !line.startsWith('#')) - .map(line => line === '!deleted' ? null : line); - } else if (source && typeof source === 'object' && Array.isArray(source.nodes)) { - schema = source.schema; - records = source.nodes; - } else if (source && Array.isArray(source.slots)) { - return normalizeManifest(source.schema, source.slots.map(slot => slot?.deleted ? null : slot?.path)); - } else { - throw new TypeError('CheckboxTree manifest must be text or an object with a nodes array.'); - } - - return normalizeManifest(schema, records); -} - -function normalizeManifest(schema, records) { - if (schema !== 1) { - throw new Error(`Unsupported CheckboxTree manifest schema: ${schema}`); - } - - const pathToSlot = new Map(); - const slots = records.map((record, index) => { - if (record === null || record === '!deleted') { - return { path: null, deleted: true }; - } - if (typeof record !== 'string' || !record || record.trim() !== record) { - throw new TypeError(`Invalid CheckboxTree manifest entry at slot ${index}.`); - } - if (pathToSlot.has(record)) { - throw new Error(`Duplicate CheckboxTree manifest path: ${record}`); - } - pathToSlot.set(record, index); - return { path: record, deleted: false }; - }); - - return { schema: 1, slots, pathToSlot }; -} - -export function setBit(bytes, bitIndex, value) { - const byteIndex = Math.floor(bitIndex / 8); - const mask = 1 << (bitIndex % 8); - if (value) bytes[byteIndex] |= mask; - else bytes[byteIndex] &= ~mask; -} - -export function getBit(bytes, bitIndex) { - const byteIndex = Math.floor(bitIndex / 8); - return byteIndex < bytes.length && (bytes[byteIndex] & (1 << (bitIndex % 8))) !== 0; -} - -export function bytesToBase64Url(bytes) { - let binary = ''; - for (const byte of bytes) binary += String.fromCharCode(byte); - return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); -} - -export function base64UrlToBytes(value, maximumLength = Infinity) { - if (typeof value !== 'string' || !/^[A-Za-z0-9_-]*$/.test(value) || value.length % 4 === 1) { - throw new Error('Invalid Base64URL value.'); - } - if (Number.isFinite(maximumLength) && value.length > Math.ceil(maximumLength * 4 / 3) + 2) { - throw new Error('Encoded tree state exceeds the manifest size.'); - } - const base64 = value.replace(/-/g, '+').replace(/_/g, '/'); - const binary = atob(base64 + '='.repeat((4 - base64.length % 4) % 4)); - if (binary.length > maximumLength) throw new Error('Encoded tree state exceeds the manifest size.'); - return Uint8Array.from(binary, character => character.charCodeAt(0)); -} - -function trimTrailingZeroBytes(bytes) { - let end = bytes.length; - while (end && bytes[end - 1] === 0) end--; - return bytes.slice(0, end); -} - -function encodeUnsignedVarint(value) { - const bytes = []; - do { - let byte = value & 0x7f; - value = Math.floor(value / 128); - if (value) byte |= 0x80; - bytes.push(byte); - } while (value); - return bytes; -} - -function decodeUnsignedVarint(bytes, offset) { - let value = 0; - let multiplier = 1; - for (let index = offset; index < bytes.length && index < offset + 8; index++) { - const byte = bytes[index]; - value += (byte & 0x7f) * multiplier; - if ((byte & 0x80) === 0) return { value, offset: index + 1 }; - multiplier *= 128; - if (!Number.isSafeInteger(value) || !Number.isSafeInteger(multiplier)) break; - } - throw new Error('Invalid adaptive tree-state integer.'); -} - -function encodeSparseIndexes(indexes) { - const bytes = []; - let previous = -1; - for (const index of indexes) { - bytes.push(...encodeUnsignedVarint(index - previous - 1)); - previous = index; - } - return bytes; -} - -function decodeSparseIndexes(bytes, offset, limit) { - const indexes = new Set(); - let previous = -1; - while (offset < bytes.length) { - const decoded = decodeUnsignedVarint(bytes, offset); - const index = previous + decoded.value + 1; - if (index >= limit) throw new Error('Sparse tree state contains an out-of-range slot.'); - indexes.add(index); - previous = index; - offset = decoded.offset; - } - return indexes; -} - -export function encodeAdaptiveBitset(values, slotCount) { - const enabled = []; - const disabled = []; - const denseBytes = new Uint8Array(Math.ceil(slotCount / 8)); - for (const [slot, value] of values) { - (value ? enabled : disabled).push(slot); - setBit(denseBytes, slot, value); - } - enabled.sort((left, right) => left - right); - disabled.sort((left, right) => left - right); - if (enabled.length === 0) return new Uint8Array(); - - const candidates = [ - Uint8Array.from([0, ...trimTrailingZeroBytes(denseBytes)]), - Uint8Array.from([1, ...encodeSparseIndexes(enabled)]), - Uint8Array.from([2, ...encodeUnsignedVarint(slotCount), ...encodeSparseIndexes(disabled)]) - ]; - return candidates.reduce((shortest, candidate) => - candidate.length < shortest.length ? candidate : shortest - ); -} - -export function decodeAdaptiveBitset(bytes, slotCount) { - if (bytes.length === 0) return () => false; - const mode = bytes[0]; - if (mode === 0) { - const dense = bytes.slice(1); - if (dense.length > Math.ceil(slotCount / 8)) { - throw new Error('Dense tree state exceeds the manifest size.'); - } - return slot => getBit(dense, slot); - } - if (mode === 1) { - const enabled = decodeSparseIndexes(bytes, 1, slotCount); - return slot => enabled.has(slot); - } - if (mode === 2) { - const coverage = decodeUnsignedVarint(bytes, 1); - if (coverage.value > slotCount) throw new Error('Tree-state coverage exceeds the manifest size.'); - const disabled = decodeSparseIndexes(bytes, coverage.offset, coverage.value); - return slot => slot < coverage.value && !disabled.has(slot); - } - throw new Error('Unknown adaptive tree-state mode.'); -} - -function asManifest(manifest) { - return manifest?.pathToSlot instanceof Map ? manifest : loadManifest(manifest); -} - -export function validateManifest(manifest, tree) { - const normalized = asManifest(manifest); - const treePaths = new Set(tree.nodesByPath.keys()); - return { - valid: Array.from(treePaths).every(path => normalized.pathToSlot.has(path)), - unregisteredPaths: Array.from(treePaths).filter(path => !normalized.pathToSlot.has(path)), - missingPaths: normalized.slots - .filter(slot => !slot.deleted && !treePaths.has(slot.path)) - .map(slot => slot.path) - }; -} - -export function validateManifestEvolution(previous, next) { - const oldManifest = asManifest(previous); - const newManifest = asManifest(next); - const errors = []; - - if (newManifest.slots.length < oldManifest.slots.length) { - errors.push('Manifest slots cannot be removed; use tombstones.'); - } - oldManifest.slots.forEach((oldSlot, index) => { - const newSlot = newManifest.slots[index]; - if (!newSlot) return; - if (oldSlot.deleted && !newSlot.deleted) { - errors.push(`Tombstoned slot ${index} cannot be reused.`); - } - if (!oldSlot.deleted && !newSlot.deleted && oldSlot.path !== newSlot.path) { - errors.push(`Slot ${index} changed path; confirm this is a logical rename or move.`); - } - }); - - return { valid: errors.length === 0, errors }; -} - -export function encodeTreeState(tree, manifest) { - const normalized = asManifest(manifest); - const checkedValues = new Map(); - const expandedValues = new Map(); - - for (const [path, node] of tree.nodesByPath) { - const slot = normalized.pathToSlot.get(path); - if (slot === undefined) continue; - const item = tree.itemForNode(node.id); - const checkbox = item && tree.directCheckbox(item); - const toggle = item?.querySelector(':scope > .checkbox-tree__row > .checkbox-tree__toggle'); - checkedValues.set(slot, Boolean(checkbox?.checked)); - expandedValues.set(slot, toggle?.getAttribute('aria-expanded') === 'true'); - } - - const checkedValue = bytesToBase64Url(encodeAdaptiveBitset(checkedValues, normalized.slots.length)); - const expandedValue = bytesToBase64Url(encodeAdaptiveBitset(expandedValues, normalized.slots.length)); - return { version: SERIALIZATION_VERSION, checked: checkedValue || null, expanded: expandedValue || null }; -} - -export function decodeTreeState(fragment, manifest) { - const normalized = asManifest(manifest); - const value = fragment.startsWith('#') ? fragment.slice(1) : fragment; - const params = new URLSearchParams(value); - if (params.get('v') !== String(SERIALIZATION_VERSION)) { - return { applied: false, version: params.get('v'), warnings: ['Missing or unsupported serialization version.'] }; - } - const maximumLength = Math.max( - Math.ceil(normalized.slots.length / 8) + 1, - normalized.slots.length * 2 + 10 - ); - try { - const checked = base64UrlToBytes(params.get('c') ?? '', maximumLength); - const expanded = base64UrlToBytes(params.get('x') ?? '', maximumLength); - decodeAdaptiveBitset(checked, normalized.slots.length); - decodeAdaptiveBitset(expanded, normalized.slots.length); - return { - applied: true, - version: SERIALIZATION_VERSION, - checked, - expanded, - warnings: [] - }; - } catch (error) { - return { applied: false, version: SERIALIZATION_VERSION, warnings: [error.message] }; - } -} - -export function applyTreeState(tree, manifest, state) { - if (!state?.applied) return false; - const normalized = asManifest(manifest); - let isChecked; - let isExpanded; - try { - isChecked = decodeAdaptiveBitset(state.checked, normalized.slots.length); - isExpanded = decodeAdaptiveBitset(state.expanded, normalized.slots.length); - } catch { - return false; - } - for (const [path, node] of tree.nodesByPath) { - const slot = normalized.pathToSlot.get(path); - if (slot === undefined) continue; - const item = tree.itemForNode(node.id); - const checkbox = item && tree.directCheckbox(item); - if (checkbox && !checkbox.disabled) checkbox.checked = isChecked(slot); - tree.setExpanded(item, isExpanded(slot)); - } - tree.updateAllParentCheckboxes(); - return true; -} - -export function serializeStateToFragment(tree, manifest) { - const state = encodeTreeState(tree, manifest); - const params = new URLSearchParams({ v: String(state.version) }); - if (state.checked) params.set('c', state.checked); - if (state.expanded) params.set('x', state.expanded); - return `#${params}`; -} - -export function restoreStateFromLocation(tree, manifest, location = window.location) { - const state = decodeTreeState(location.hash ?? '', manifest); - if (state.applied) applyTreeState(tree, manifest, state); - return state; -} - export class CheckboxTree { constructor(container, options = {}) { if (!(container instanceof Element)) { @@ -314,8 +47,10 @@ export class CheckboxTree { this.container = container; this.options = { ...DEFAULT_OPTIONS, ...options }; + assertStateEncoding(this.options.stateEncoding); this.nodesById = new Map(); this.nodesByPath = new Map(); + this.itemsById = new Map(); this.rootNodes = []; this.handleChange = this.handleChange.bind(this); this.handleClick = this.handleClick.bind(this); @@ -332,6 +67,7 @@ export class CheckboxTree { this.nodesById.clear(); this.nodesByPath.clear(); + this.itemsById.clear(); this.rootNodes = nodes; this.indexNodes(nodes); this.render(); @@ -371,6 +107,7 @@ export class CheckboxTree { this.container.replaceChildren(); this.nodesById.clear(); this.nodesByPath.clear(); + this.itemsById.clear(); } indexNodes(nodes, parentPath = '') { @@ -408,6 +145,7 @@ export class CheckboxTree { const item = document.createElement('div'); item.className = 'checkbox-tree__item'; item.dataset.nodeId = node.id; + this.itemsById.set(node.id, item); const row = document.createElement('div'); row.className = 'checkbox-tree__row'; @@ -524,8 +262,7 @@ export class CheckboxTree { } itemForNode(id) { - return Array.from(this.container.querySelectorAll('.checkbox-tree__item')) - .find(item => item.dataset.nodeId === id) ?? null; + return this.itemsById.get(id) ?? null; } updateAncestorCheckboxes(item) { diff --git a/vendor/checkbox-tree/state-codec.js b/vendor/checkbox-tree/state-codec.js new file mode 100644 index 0000000..8a982a8 --- /dev/null +++ b/vendor/checkbox-tree/state-codec.js @@ -0,0 +1,206 @@ +// Manifest parsing and binary codecs. This module has no DOM dependencies. +export const SERIALIZATION_VERSION = 1; +export const PATH_DELIMITER = '>'; +const STATE_ENCODINGS = new Set(['adaptive', 'dense', 'tiered']); + +export function loadManifest(source) { + let records; + let schema = 1; + + if (typeof source === 'string') { + records = source.split(/\r?\n/) + .map(line => line.trim()) + .filter(line => line && !line.startsWith('#')) + .map(line => line === '!deleted' ? null : line); + } else if (source && typeof source === 'object' && Array.isArray(source.nodes)) { + schema = source.schema; + records = source.nodes; + } else if (source && Array.isArray(source.slots)) { + return normalizeManifest(source.schema, source.slots.map(slot => slot?.deleted ? null : slot?.path)); + } else { + throw new TypeError('CheckboxTree manifest must be text or an object with a nodes array.'); + } + + return normalizeManifest(schema, records); +} + +function normalizeManifest(schema, records) { + if (schema !== 1) { + throw new Error(`Unsupported CheckboxTree manifest schema: ${schema}`); + } + + const pathToSlot = new Map(); + const slots = records.map((record, index) => { + if (record === null || record === '!deleted') { + return { path: null, deleted: true }; + } + if (typeof record !== 'string' || !record || record.trim() !== record) { + throw new TypeError(`Invalid CheckboxTree manifest entry at slot ${index}.`); + } + if (pathToSlot.has(record)) { + throw new Error(`Duplicate CheckboxTree manifest path: ${record}`); + } + pathToSlot.set(record, index); + return { path: record, deleted: false }; + }); + + return { schema: 1, slots, pathToSlot }; +} + +export function setBit(bytes, bitIndex, value) { + const byteIndex = Math.floor(bitIndex / 8); + const mask = 1 << (bitIndex % 8); + if (value) bytes[byteIndex] |= mask; + else bytes[byteIndex] &= ~mask; +} + +export function getBit(bytes, bitIndex) { + const byteIndex = Math.floor(bitIndex / 8); + return byteIndex < bytes.length && (bytes[byteIndex] & (1 << (bitIndex % 8))) !== 0; +} + +export function bytesToBase64Url(bytes) { + let binary = ''; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); +} + +export function base64UrlToBytes(value, maximumLength = Infinity) { + if (typeof value !== 'string' || !/^[A-Za-z0-9_-]*$/.test(value) || value.length % 4 === 1) { + throw new Error('Invalid Base64URL value.'); + } + if (Number.isFinite(maximumLength) && value.length > Math.ceil(maximumLength * 4 / 3) + 2) { + throw new Error('Encoded tree state exceeds the manifest size.'); + } + const base64 = value.replace(/-/g, '+').replace(/_/g, '/'); + const binary = atob(base64 + '='.repeat((4 - base64.length % 4) % 4)); + if (binary.length > maximumLength) throw new Error('Encoded tree state exceeds the manifest size.'); + return Uint8Array.from(binary, character => character.charCodeAt(0)); +} + +function trimTrailingZeroBytes(bytes) { + let end = bytes.length; + while (end && bytes[end - 1] === 0) end--; + return bytes.slice(0, end); +} + +function encodeUnsignedVarint(value) { + const bytes = []; + do { + let byte = value & 0x7f; + value = Math.floor(value / 128); + if (value) byte |= 0x80; + bytes.push(byte); + } while (value); + return bytes; +} + +function decodeUnsignedVarint(bytes, offset) { + let value = 0; + let multiplier = 1; + for (let index = offset; index < bytes.length && index < offset + 8; index++) { + const byte = bytes[index]; + value += (byte & 0x7f) * multiplier; + if ((byte & 0x80) === 0) return { value, offset: index + 1 }; + multiplier *= 128; + if (!Number.isSafeInteger(value) || !Number.isSafeInteger(multiplier)) break; + } + throw new Error('Invalid adaptive tree-state integer.'); +} + +function encodeSparseIndexes(indexes) { + const bytes = []; + let previous = -1; + for (const index of indexes) { + bytes.push(...encodeUnsignedVarint(index - previous - 1)); + previous = index; + } + return bytes; +} + +function decodeSparseIndexes(bytes, offset, limit) { + const indexes = new Set(); + let previous = -1; + while (offset < bytes.length) { + const decoded = decodeUnsignedVarint(bytes, offset); + const index = previous + decoded.value + 1; + if (index >= limit) throw new Error('Sparse tree state contains an out-of-range slot.'); + indexes.add(index); + previous = index; + offset = decoded.offset; + } + return indexes; +} + +export function encodeAdaptiveBitset(values, slotCount) { + const enabled = []; + const disabled = []; + const denseBytes = new Uint8Array(Math.ceil(slotCount / 8)); + for (const [slot, value] of values) { + (value ? enabled : disabled).push(slot); + setBit(denseBytes, slot, value); + } + enabled.sort((left, right) => left - right); + disabled.sort((left, right) => left - right); + if (enabled.length === 0) return new Uint8Array(); + + const candidates = [ + Uint8Array.from([0, ...trimTrailingZeroBytes(denseBytes)]), + Uint8Array.from([1, ...encodeSparseIndexes(enabled)]), + Uint8Array.from([2, ...encodeUnsignedVarint(slotCount), ...encodeSparseIndexes(disabled)]) + ]; + return candidates.reduce((shortest, candidate) => + candidate.length < shortest.length ? candidate : shortest + ); +} + +export function decodeAdaptiveBitset(bytes, slotCount) { + if (bytes.length === 0) return () => false; + const mode = bytes[0]; + if (mode === 0) { + const dense = bytes.slice(1); + if (dense.length > Math.ceil(slotCount / 8)) { + throw new Error('Dense tree state exceeds the manifest size.'); + } + return slot => getBit(dense, slot); + } + if (mode === 1) { + const enabled = decodeSparseIndexes(bytes, 1, slotCount); + return slot => enabled.has(slot); + } + if (mode === 2) { + const coverage = decodeUnsignedVarint(bytes, 1); + if (coverage.value > slotCount) throw new Error('Tree-state coverage exceeds the manifest size.'); + const disabled = decodeSparseIndexes(bytes, coverage.offset, coverage.value); + return slot => slot < coverage.value && !disabled.has(slot); + } + throw new Error('Unknown adaptive tree-state mode.'); +} + + +export function encodeDenseBitset(values, slotCount) { + const bytes = new Uint8Array(Math.ceil(slotCount / 8)); + for (const [slot, value] of values) setBit(bytes, slot, value); + return trimTrailingZeroBytes(bytes); +} + +export function decodeDenseBitset(bytes, slotCount) { + if (bytes.length > Math.ceil(slotCount / 8)) { + throw new Error('Dense tree state exceeds the manifest size.'); + } + return slot => getBit(bytes, slot); +} + +export function maximumEncodedLength(slotCount) { + return Math.max(Math.ceil(slotCount / 8) + 1, slotCount * 2 + 10); +} + +export function assertStateEncoding(encoding) { + if (!STATE_ENCODINGS.has(encoding)) { + throw new Error(`Unsupported CheckboxTree state encoding: ${encoding}`); + } +} + +export function asManifestSource(manifest) { + return manifest?.pathToSlot instanceof Map ? manifest : loadManifest(manifest); +} diff --git a/vendor/checkbox-tree/tree-state.js b/vendor/checkbox-tree/tree-state.js new file mode 100644 index 0000000..ac6ed6a --- /dev/null +++ b/vendor/checkbox-tree/tree-state.js @@ -0,0 +1,310 @@ +// Adapts pure state codecs to CheckboxTree's rendered node model and URL format. +import { + PATH_DELIMITER, + SERIALIZATION_VERSION, + asManifestSource as asManifest, + assertStateEncoding, + base64UrlToBytes, + bytesToBase64Url, + decodeAdaptiveBitset, + decodeDenseBitset, + encodeAdaptiveBitset, + encodeDenseBitset, + maximumEncodedLength +} from './state-codec.js'; + +export function validateManifest(manifest, tree) { + const normalized = asManifest(manifest); + const treePaths = new Set(tree.nodesByPath.keys()); + return { + valid: Array.from(treePaths).every(path => normalized.pathToSlot.has(path)), + unregisteredPaths: Array.from(treePaths).filter(path => !normalized.pathToSlot.has(path)), + missingPaths: normalized.slots + .filter(slot => !slot.deleted && !treePaths.has(slot.path)) + .map(slot => slot.path) + }; +} + +export function validateManifestEvolution(previous, next) { + const oldManifest = asManifest(previous); + const newManifest = asManifest(next); + const errors = []; + + if (newManifest.slots.length < oldManifest.slots.length) { + errors.push('Manifest slots cannot be removed; use tombstones.'); + } + oldManifest.slots.forEach((oldSlot, index) => { + const newSlot = newManifest.slots[index]; + if (!newSlot) return; + if (oldSlot.deleted && !newSlot.deleted) { + errors.push(`Tombstoned slot ${index} cannot be reused.`); + } + if (!oldSlot.deleted && !newSlot.deleted && oldSlot.path !== newSlot.path) { + errors.push(`Slot ${index} changed path; confirm this is a logical rename or move.`); + } + }); + + return { valid: errors.length === 0, errors }; +} + +function collectTreeValues(tree, manifest) { + const normalized = asManifest(manifest); + const checkedValues = new Map(); + const expandedValues = new Map(); + + for (const [path, node] of tree.nodesByPath) { + const slot = normalized.pathToSlot.get(path); + if (slot === undefined) continue; + const item = tree.itemForNode(node.id); + const checkbox = item && tree.directCheckbox(item); + const toggle = item?.querySelector(':scope > .checkbox-tree__row > .checkbox-tree__toggle'); + checkedValues.set(slot, Boolean(checkbox?.checked)); + expandedValues.set(slot, toggle?.getAttribute('aria-expanded') === 'true'); + } + + return { normalized, checkedValues, expandedValues }; +} + +function nodeState(tree, node) { + const item = tree.itemForNode(node.id); + const checkbox = item && tree.directCheckbox(item); + const toggle = item?.querySelector(':scope > .checkbox-tree__row > .checkbox-tree__toggle'); + return { + checked: Boolean(checkbox?.checked), + hasChecked: Boolean(checkbox), + expanded: toggle?.getAttribute('aria-expanded') === 'true', + hasExpanded: Boolean(toggle) + }; +} + +function encodeTieredState(tree, normalized) { + const checkedByDepth = new Map(); + const expandedByDepth = new Map(); + const statesByPath = new Map(); + + for (const [path, node] of tree.nodesByPath) { + const slot = normalized.pathToSlot.get(path); + if (slot === undefined) continue; + const depth = path.split(PATH_DELIMITER).length; + const parentPath = path.includes(PATH_DELIMITER) + ? path.slice(0, path.lastIndexOf(PATH_DELIMITER)) + : null; + const parent = statesByPath.get(parentPath) ?? { + checked: false, + hasChecked: false, + expanded: false, + hasExpanded: false + }; + const current = nodeState(tree, node); + if (current.hasChecked) { + checkedByDepth.set(depth, checkedByDepth.get(depth) ?? new Map()); + checkedByDepth.get(depth).set(slot, current.checked !== (parent.hasChecked ? parent.checked : false)); + } + if (current.hasExpanded) { + expandedByDepth.set(depth, expandedByDepth.get(depth) ?? new Map()); + expandedByDepth.get(depth).set(slot, current.expanded); + } + statesByPath.set(path, current); + } + + const fields = new Map([['n', String(normalized.slots.length)]]); + for (const [depth, values] of checkedByDepth) { + const encoded = bytesToBase64Url(encodeAdaptiveBitset(values, normalized.slots.length)); + if (encoded) fields.set(`c${depth}`, encoded); + } + for (const [depth, values] of expandedByDepth) { + const encoded = bytesToBase64Url(encodeAdaptiveBitset(values, normalized.slots.length)); + if (encoded) fields.set(`x${depth}`, encoded); + } + return fields; +} + +export function encodeTreeState(tree, manifest, encoding = tree.options?.stateEncoding ?? 'adaptive') { + assertStateEncoding(encoding); + const { normalized, checkedValues, expandedValues } = collectTreeValues(tree, manifest); + + if (encoding === 'tiered') { + return { version: SERIALIZATION_VERSION, encoding, fields: encodeTieredState(tree, normalized) }; + } + + const encode = encoding === 'dense' ? encodeDenseBitset : encodeAdaptiveBitset; + const checkedValue = bytesToBase64Url(encode(checkedValues, normalized.slots.length)); + const expandedValue = bytesToBase64Url(encode(expandedValues, normalized.slots.length)); + return { + version: SERIALIZATION_VERSION, + encoding, + checked: checkedValue || null, + expanded: expandedValue || null + }; +} + +function decodeField(params, name, normalized, encoding) { + const maximumLength = encoding === 'dense' + ? Math.ceil(normalized.slots.length / 8) + : maximumEncodedLength(normalized.slots.length); + const bytes = base64UrlToBytes(params.get(name) ?? '', maximumLength); + const decode = encoding === 'dense' ? decodeDenseBitset : decodeAdaptiveBitset; + decode(bytes, normalized.slots.length); + return bytes; +} + +export function decodeTreeState(fragment, manifest, encoding = 'adaptive') { + assertStateEncoding(encoding); + const normalized = asManifest(manifest); + const value = fragment.startsWith('#') ? fragment.slice(1) : fragment; + const params = new URLSearchParams(value); + if (params.get('v') !== String(SERIALIZATION_VERSION)) { + return { applied: false, version: params.get('v'), encoding, warnings: ['Missing or unsupported serialization version.'] }; + } + try { + if (encoding === 'tiered') { + if (params.has('c') || params.has('x')) { + throw new Error('Tiered tree state contains non-tiered fields.'); + } + const maximumDepth = normalized.slots.reduce((depth, slot) => + slot.deleted ? depth : Math.max(depth, slot.path.split(PATH_DELIMITER).length) + , 0); + for (const key of params.keys()) { + const match = /^([cx])(\d+)$/.exec(key); + if (match && (Number(match[2]) < 1 || Number(match[2]) > maximumDepth)) { + throw new Error(`Tree state contains an invalid tier: ${key}`); + } + } + const coverage = Number(params.get('n')); + if (!Number.isSafeInteger(coverage) || coverage < 0 || coverage > normalized.slots.length) { + throw new Error('Tiered tree state has invalid manifest coverage.'); + } + const checkedTiers = new Map(); + const expandedTiers = new Map(); + for (let depth = 1; depth <= maximumDepth; depth++) { + checkedTiers.set(depth, decodeField(params, `c${depth}`, normalized, 'adaptive')); + expandedTiers.set(depth, decodeField(params, `x${depth}`, normalized, 'adaptive')); + } + return { + applied: true, + version: SERIALIZATION_VERSION, + encoding, + coverage, + checkedTiers, + expandedTiers, + warnings: [] + }; + } + + for (const key of params.keys()) { + if (key === 'n' || /^[cx]\d+$/.test(key)) { + throw new Error('Tree state contains tiered fields for a non-tiered encoding.'); + } + } + + const checked = decodeField(params, 'c', normalized, encoding); + const expanded = decodeField(params, 'x', normalized, encoding); + return { + applied: true, + version: SERIALIZATION_VERSION, + encoding, + checked, + expanded, + warnings: [] + }; + } catch (error) { + return { applied: false, version: SERIALIZATION_VERSION, encoding, warnings: [error.message] }; + } +} + +export function applyTreeState(tree, manifest, state) { + if (!state?.applied) return false; + const normalized = asManifest(manifest); + if (state.encoding === 'tiered') return applyTieredTreeState(tree, normalized, state); + let isChecked; + let isExpanded; + try { + const decode = state.encoding === 'dense' ? decodeDenseBitset : decodeAdaptiveBitset; + isChecked = decode(state.checked, normalized.slots.length); + isExpanded = decode(state.expanded, normalized.slots.length); + } catch { + return false; + } + for (const [path, node] of tree.nodesByPath) { + const slot = normalized.pathToSlot.get(path); + if (slot === undefined) continue; + const item = tree.itemForNode(node.id); + const checkbox = item && tree.directCheckbox(item); + if (checkbox && !checkbox.disabled) checkbox.checked = isChecked(slot); + tree.setExpanded(item, isExpanded(slot)); + } + tree.updateAllParentCheckboxes(); + return true; +} + +function applyTieredTreeState(tree, normalized, state) { + const resolvedByPath = new Map(); + const checkedDecoders = new Map(); + const expandedDecoders = new Map(); + try { + for (const [depth, bytes] of state.checkedTiers) { + checkedDecoders.set(depth, decodeAdaptiveBitset(bytes, normalized.slots.length)); + } + for (const [depth, bytes] of state.expandedTiers) { + expandedDecoders.set(depth, decodeAdaptiveBitset(bytes, normalized.slots.length)); + } + } catch { + return false; + } + + for (const [path, node] of tree.nodesByPath) { + const slot = normalized.pathToSlot.get(path); + if (slot === undefined) continue; + const depth = path.split(PATH_DELIMITER).length; + const parentPath = path.includes(PATH_DELIMITER) + ? path.slice(0, path.lastIndexOf(PATH_DELIMITER)) + : null; + const parent = resolvedByPath.get(parentPath) ?? { + checked: false, + hasChecked: false, + expanded: false, + hasExpanded: false + }; + const item = tree.itemForNode(node.id); + const checkbox = item && tree.directCheckbox(item); + const toggle = item?.querySelector(':scope > .checkbox-tree__row > .checkbox-tree__toggle'); + const isCovered = slot < state.coverage; + const checked = checkbox && isCovered + ? (parent.hasChecked ? parent.checked : false) !== checkedDecoders.get(depth)(slot) + : false; + const expanded = Boolean(toggle && isCovered && expandedDecoders.get(depth)(slot)); + if (checkbox && !checkbox.disabled) checkbox.checked = checked; + tree.setExpanded(item, expanded); + resolvedByPath.set(path, { + checked, + hasChecked: Boolean(checkbox), + expanded, + hasExpanded: Boolean(toggle) + }); + } + tree.updateAllParentCheckboxes(); + return true; +} + +export function serializeStateToFragment(tree, manifest, encoding = tree.options?.stateEncoding ?? 'adaptive') { + const state = encodeTreeState(tree, manifest, encoding); + const params = new URLSearchParams({ v: String(state.version) }); + if (state.encoding === 'tiered') { + for (const [name, value] of state.fields) params.set(name, value); + } else { + if (state.checked) params.set('c', state.checked); + if (state.expanded) params.set('x', state.expanded); + } + return `#${params}`; +} + +export function restoreStateFromLocation( + tree, + manifest, + location = window.location, + encoding = tree.options?.stateEncoding ?? 'adaptive' +) { + const state = decodeTreeState(location.hash ?? '', manifest, encoding); + if (state.applied) applyTreeState(tree, manifest, state); + return state; +} From d74b38311ef82bdbc411f570d6e84c10804c727c Mon Sep 17 00:00:00 2001 From: aaronaxvig Date: Tue, 21 Jul 2026 21:34:28 -0500 Subject: [PATCH 23/23] Add Spirea details --- src/data/plants.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/data/plants.js b/src/data/plants.js index de07a84..f583d27 100644 --- a/src/data/plants.js +++ b/src/data/plants.js @@ -357,14 +357,17 @@ const bushes = [ new Bush("Spirea", { species: "Spirea", measurements: [{ canopyDiameter: geo.ft(4.92), height: geo.ft(3.28) }], + gardenOrgId: "79059", }).add(geo.offset(lotCorner), geo.east(geo.ft(70)), geo.south(geo.ft(31))), new Bush("Spirea", { species: "Spirea", measurements: [{ canopyDiameter: geo.ft(4.92), height: geo.ft(3.28) }], + gardenOrgId: "79059", }).add(geo.offset(lotCorner), geo.east(geo.ft(74)), geo.south(geo.ft(31))), new Bush("Spirea", { species: "Spirea", measurements: [{ canopyDiameter: geo.ft(4.92), height: geo.ft(3.28) }], + gardenOrgId: "79068", }).add(geo.offset(lotCorner), geo.east(geo.ft(78)), geo.south(geo.ft(31))), ];