Prototype

This commit is contained in:
2026-06-26 11:23:09 -05:00
commit 2a52e0546e
19 changed files with 1406 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
{
"chat.tools.terminal.autoApprove": {
"/^python3 -m http\\.server 4173$/": {
"approve": true,
"matchCommandLine": true
}
}
}
+97
View File
@@ -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.
+109
View File
@@ -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 <http://localhost:4173>.
## 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.
BIN
View File
Binary file not shown.
+50
View File
@@ -0,0 +1,50 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Yard Map</title>
<link
rel="stylesheet"
href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
crossorigin=""
/>
<link rel="stylesheet" href="./styles.css" />
</head>
<body>
<div class="app-shell">
<aside class="panel">
<p class="eyebrow">Static map</p>
<h1>Yard Atlas</h1>
<p class="intro">
Leaflet renders OpenStreetMap tiles underneath a client-side geometry
pipeline. Source files stay as JavaScript so you can author points and
shapes with absolute coordinates or offset math.
</p>
<div class="legend" id="legend"></div>
<section class="notes">
<h2>Authoring style</h2>
<pre><code>new House()
.trace([
[-100.8988347540, 46.8268080234],
[-100.8986668041, 46.8268307100],
])
new Sprinkler("Front bed sprinkler")
.add(offset(lotCorner), south(ft(15)), east(ft(10)))</code></pre>
</section>
</aside>
<main>
<div id="map" aria-label="Map of yard"></div>
</main>
</div>
<script
src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
crossorigin=""
></script>
<script type="module" src="./src/main.js"></script>
</body>
</html>
+5
View File
@@ -0,0 +1,5 @@
{
"name": "yardmap",
"private": true,
"type": "module"
}
+7
View File
@@ -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;
+19
View File
@@ -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];
+42
View File
@@ -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];
+8
View File
@@ -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))),
];
+51
View File
@@ -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];
+106
View File
@@ -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];
+1
View File
@@ -0,0 +1 @@
export const lotAssumedNorthClockwiseDegrees = -11.25369719051117;
+20
View File
@@ -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,
],
);
+125
View File
@@ -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;
}
+204
View File
@@ -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()),
};
}
+279
View File
@@ -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 };
+139
View File
@@ -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: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
}).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 = `<strong>${escapeHtml(name)}</strong>`;
const subtitle = name === label ? "" : `<div>${escapeHtml(label)}</div>`;
const allEntries = [...entries, ...relationshipEntries];
if (allEntries.length === 0) {
return `${title}${subtitle}`;
}
const detailRows = allEntries
.map(([key, value]) => `<div><span>${escapeHtml(formatDetailLabel(key))}:</span> ${escapeHtml(formatDetailValue(key, value))}</div>`)
.join("");
return `${title}${subtitle}<div>${detailRows}</div>`;
}
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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
+136
View File
@@ -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;
}
}