commit 2a52e0546e362a6c7d9b2f360d956d13e3bf245e Author: Aaron Axvig Date: Fri Jun 26 11:23:09 2026 -0500 Prototype 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 0000000..764100e Binary files /dev/null and b/buildings.gpkg differ diff --git a/index.html b/index.html new file mode 100644 index 0000000..cd2cb15 --- /dev/null +++ b/index.html @@ -0,0 +1,50 @@ + + + + + + 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