Prototype
This commit is contained in:
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"chat.tools.terminal.autoApprove": {
|
||||
"/^python3 -m http\\.server 4173$/": {
|
||||
"approve": true,
|
||||
"matchCommandLine": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Binary file not shown.
+50
@@ -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>
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "yardmap",
|
||||
"private": true,
|
||||
"type": "module"
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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];
|
||||
@@ -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];
|
||||
@@ -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))),
|
||||
];
|
||||
@@ -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];
|
||||
@@ -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];
|
||||
@@ -0,0 +1 @@
|
||||
export const lotAssumedNorthClockwiseDegrees = -11.25369719051117;
|
||||
@@ -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,
|
||||
],
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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()),
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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: '© <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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
+136
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user