Use local PMTiles basemap
@@ -0,0 +1,103 @@
|
||||
# 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: MapLibre GL JS.
|
||||
- Basemap: committed Mandan-area Protomaps vector tiles in `tiles/mandan.pmtiles`.
|
||||
- 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 labels, geometry constraints, detail field schemas, and style defaults.
|
||||
- `src/lib/yard-features.js`: typed feature classes, stable ids, groups, parent-child relationships, schema metadata, GeoJSON export.
|
||||
- `src/data/*.js`: yard content split by domain.
|
||||
- `src/main.js`: MapLibre map, yard layers, legend, popup rendering.
|
||||
|
||||
Top-level exported data shape from `src/data/yard.js`:
|
||||
|
||||
- `type: "FeatureCollection"`
|
||||
- `schemaVersion: 2`
|
||||
- `lifecycleFields: { plantedDate: "date", removedDate: "date" }`
|
||||
- `featureTypes: [...]`
|
||||
- `features: [...]`
|
||||
- `groups: [...]`
|
||||
|
||||
Each feature currently exports:
|
||||
|
||||
- `properties.id`
|
||||
- `properties.kind`
|
||||
- `properties.label`
|
||||
- `properties.name`
|
||||
- `properties.plantedDate`
|
||||
- `properties.removedDate`
|
||||
- `properties.parentId`
|
||||
- `properties.groupIds`
|
||||
- `properties.details`
|
||||
|
||||
## 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. The basemap uses local vector tiles and is overzoomed from source zoom 15
|
||||
through the yard map's maximum zoom of 24. Symbol layers are deliberately
|
||||
omitted so the map has no remote font or sprite dependency.
|
||||
|
||||
## 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,112 @@
|
||||
# Yard Map
|
||||
|
||||
Static yard mapping site built with MapLibre, PMTiles, and plain browser modules.
|
||||
|
||||
## Status
|
||||
|
||||
The project currently has:
|
||||
|
||||
- A static MapLibre map backed by a local Mandan-area PMTiles vector archive.
|
||||
- Client-side JavaScript-authored yard geometry converted to GeoJSON at load time.
|
||||
- Typed feature classes with centralized styling.
|
||||
- Schema-versioned yard data with a top-level feature type catalog.
|
||||
- Per-anchor local coordinate rotation for offset-based authoring.
|
||||
- Feature relationship metadata (`id`, `parentId`, `groupIds`) and a top-level group catalog for future visibility controls.
|
||||
- Example yard data mostly copied from `buildings.gpkg` and then preserved as JS modules.
|
||||
|
||||
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`. Feature type definitions include the label, required geometry type, known detail fields, and default map style.
|
||||
|
||||
Local `north`, `south`, `east`, and `west` offsets are rotated per anchor. In this example, `topLeftLotCorner` in `src/data/anchors.js` carries `assumedNorthClockwiseDegrees` from `src/data/settings.js`. A negative value means that anchor's local north is counterclockwise from true north, so local east drifts a bit toward true north.
|
||||
|
||||
Most example geometry was copied from `buildings.gpkg` and preserved as JS-authored coordinates. Tree records also carry custom attributes like canopy diameter, height, and lifecycle dates for popup rendering.
|
||||
|
||||
Feature relationships are exported with each GeoJSON feature as stable `id`, `parentId`, and `groupIds` properties. Each feature also exports universal `plantedDate` and `removedDate` lifecycle fields for timeline filtering. The collection exposes `schemaVersion`, `lifecycleFields`, `featureTypes`, and `groups` at the top level. Style is resolved from the feature type registry at render time rather than stored on each exported feature. The current sample marks the porch and deck as part of the house and groups the Medora junipers under `Juniper Trees`.
|
||||
|
||||
## 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`: MapLibre rendering, legend generation, and popup rendering.
|
||||
- `tiles/mandan.pmtiles`: locally served vector basemap for the Mandan area.
|
||||
- `scripts/extract-mandan-pmtiles.sh`: reproducible Mandan tile extraction.
|
||||
- `docs/vector-tiles.md`: tile update and hosting notes.
|
||||
|
||||
## 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
|
||||
|
||||
Both the yard overlays and the local basemap are vector data, so linework stays
|
||||
sharp as the map zooms in. The Protomaps archive tops out at its source maximum
|
||||
zoom and MapLibre overzooms those vector tiles to the yard map's maximum zoom
|
||||
of 24.
|
||||
|
||||
## 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 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.
|
||||
@@ -0,0 +1,24 @@
|
||||
# Mandan vector tiles
|
||||
|
||||
The yard map reads the committed `tiles/mandan.pmtiles` archive directly in
|
||||
the browser using MapLibre GL JS. No tile server or internet-hosted raster tile
|
||||
service is required. The map omits the Protomaps symbol layers so it does not
|
||||
need remotely hosted font glyphs or sprites; the basemap is fully local.
|
||||
|
||||
The archive covers Mandan and a modest surrounding margin:
|
||||
`-101.05,46.74,-100.75,46.93`. PMTiles extraction is tile-aligned, so the
|
||||
actual archive includes a little neighboring context.
|
||||
|
||||
## Build an update
|
||||
|
||||
Install the [`pmtiles` CLI](https://docs.protomaps.com/pmtiles/cli), choose a
|
||||
dated Version 4 daily build from
|
||||
[`maps.protomaps.com/builds`](https://maps.protomaps.com/builds), and run:
|
||||
|
||||
```bash
|
||||
scripts/extract-mandan-pmtiles.sh \
|
||||
https://build.protomaps.com/YYYYMMDD.pmtiles
|
||||
```
|
||||
|
||||
The script refuses to overwrite an existing archive. Move the old archive
|
||||
aside before deliberately replacing it.
|
||||
@@ -0,0 +1,46 @@
|
||||
<!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="./vendor/maplibre/maplibre-gl.css" />
|
||||
<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">
|
||||
MapLibre renders a local PMTiles vector map 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 class="map-frame">
|
||||
<p id="map-status" class="map-status" role="status">Loading the map…</p>
|
||||
<div id="map" aria-label="Map of yard"></div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="./src/error-overlay.js"></script>
|
||||
<script src="./vendor/pmtiles.js"></script>
|
||||
<script src="./vendor/basemaps.js"></script>
|
||||
<script type="module" src="./src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "yardmap",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"check": "node --check src/main.js && node -e \"import('./src/data/yard.js')\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@protomaps/basemaps": "^5.7.2",
|
||||
"maplibre-gl": "^6.0.0",
|
||||
"pmtiles": "^4.4.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if [[ $# -lt 1 || $# -gt 2 ]]; then
|
||||
echo "Usage: $0 PROTOMAPS_SOURCE_URL [OUTPUT_FILE]" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if ! command -v pmtiles >/dev/null 2>&1; then
|
||||
echo "The pmtiles CLI is required: https://docs.protomaps.com/pmtiles/cli" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
source_url=$1
|
||||
output_file=${2:-tiles/mandan.pmtiles}
|
||||
|
||||
if [[ -e "$output_file" ]]; then
|
||||
echo "Refusing to overwrite existing file: $output_file" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Mandan city plus a modest margin for labels and roads. PMTiles extracts
|
||||
# tile-aligned bounds, so the resulting archive includes some nearby context.
|
||||
pmtiles extract \
|
||||
"$source_url" \
|
||||
"$output_file" \
|
||||
--bbox=-101.05,46.74,-100.75,46.93
|
||||
|
||||
pmtiles show "$output_file"
|
||||
@@ -0,0 +1,45 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-labelledby="title desc">
|
||||
<title id="title">Daylily</title>
|
||||
<desc id="desc">Abstract daylily clump with spindly fronds and flower stalks.</desc>
|
||||
<defs>
|
||||
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feDropShadow dx="0" dy="1.5" stdDeviation="1.2" flood-color="#1d2d16" flood-opacity="0.28" />
|
||||
</filter>
|
||||
</defs>
|
||||
<g fill="none" stroke-linecap="round" stroke-linejoin="round" filter="url(#shadow)">
|
||||
<g stroke="#315f2b">
|
||||
<path d="M32 58 C24 47 16 40 8 34" stroke-width="3.1" />
|
||||
<path d="M32 58 C25 45 22 35 20 22" stroke-width="2.7" />
|
||||
<path d="M32 58 C30 43 31 29 35 14" stroke-width="2.7" />
|
||||
<path d="M32 58 C40 45 47 37 56 30" stroke-width="3.1" />
|
||||
<path d="M32 58 C38 48 42 40 44 27" stroke-width="2.5" />
|
||||
</g>
|
||||
<g stroke="#5f8d39">
|
||||
<path d="M32 58 C21 51 13 48 5 48" stroke-width="2.4" />
|
||||
<path d="M32 58 C24 49 19 44 13 39" stroke-width="2.2" />
|
||||
<path d="M32 58 C29 47 28 38 29 27" stroke-width="2.2" />
|
||||
<path d="M32 58 C35 47 37 37 40 26" stroke-width="2.2" />
|
||||
<path d="M32 58 C42 50 50 46 60 44" stroke-width="2.4" />
|
||||
<path d="M32 58 C43 53 51 52 58 55" stroke-width="2.1" />
|
||||
</g>
|
||||
<g stroke="#6f7f32">
|
||||
<path d="M32 57 C33 43 34 30 35 16" stroke-width="1.7" />
|
||||
<path d="M31 57 C28 43 27 31 25 18" stroke-width="1.5" />
|
||||
<path d="M33 57 C42 45 47 34 50 22" stroke-width="1.5" />
|
||||
</g>
|
||||
<g fill="#f0b44c" stroke="#9f5e1d" stroke-width="1.4">
|
||||
<path d="M35 15 C30 13 29 8 32 4 C36 7 38 11 35 15" />
|
||||
<path d="M35 15 C39 11 44 12 47 16 C42 18 38 18 35 15" />
|
||||
<path d="M35 15 C39 18 39 23 36 27 C33 23 32 19 35 15" />
|
||||
<circle cx="35" cy="15" r="1.7" fill="#743719" stroke="none" />
|
||||
<path d="M25 18 C21 15 21 11 24 8 C27 11 28 15 25 18" />
|
||||
<path d="M25 18 C29 16 32 18 33 22 C29 23 26 21 25 18" />
|
||||
<path d="M25 18 C23 22 19 23 16 21 C18 17 21 16 25 18" />
|
||||
<circle cx="25" cy="18" r="1.5" fill="#743719" stroke="none" />
|
||||
<path d="M50 22 C46 19 47 15 50 12 C53 15 54 19 50 22" />
|
||||
<path d="M50 22 C54 20 58 22 59 26 C55 27 52 25 50 22" />
|
||||
<path d="M50 22 C49 26 45 28 42 26 C44 23 47 21 50 22" />
|
||||
<circle cx="50" cy="22" r="1.4" fill="#743719" stroke="none" />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
@@ -0,0 +1,33 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-labelledby="title desc">
|
||||
<title id="title">Flower</title>
|
||||
<desc id="desc">Abstract flower with paired leaves and a dahlia-like bloom.</desc>
|
||||
<defs>
|
||||
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feDropShadow dx="0" dy="1.5" stdDeviation="1.2" flood-color="#1d2d16" flood-opacity="0.28" />
|
||||
</filter>
|
||||
</defs>
|
||||
<g fill="none" stroke-linecap="round" stroke-linejoin="round" filter="url(#shadow)">
|
||||
<path d="M32 58 C32 47 32 37 32 26" stroke="#315f2b" stroke-width="3" />
|
||||
<path d="M31 46 C23 38 15 37 9 42 C17 47 24 49 31 46" fill="#5f8d39" stroke="#315f2b" stroke-width="1.8" />
|
||||
<path d="M33 43 C42 35 50 35 56 41 C48 47 40 48 33 43" fill="#6f9d42" stroke="#315f2b" stroke-width="1.8" />
|
||||
<path d="M31 54 C24 50 18 51 13 56 C20 59 26 59 31 54" fill="#547f34" stroke="#315f2b" stroke-width="1.5" />
|
||||
<path d="M33 52 C40 48 47 49 52 55 C45 58 38 58 33 52" fill="#6a963d" stroke="#315f2b" stroke-width="1.5" />
|
||||
<g fill="#c556a0" stroke="#742c5c" stroke-width="1.15">
|
||||
<path d="M32 27 C28 20 29 13 32 7 C36 13 36 20 32 27" />
|
||||
<path d="M32 27 C27 21 22 17 16 16 C18 23 24 27 32 27" />
|
||||
<path d="M32 27 C25 25 19 27 14 32 C21 35 27 33 32 27" />
|
||||
<path d="M32 27 C27 31 24 37 24 44 C30 41 33 35 32 27" />
|
||||
<path d="M32 27 C36 33 42 36 49 36 C46 30 40 27 32 27" />
|
||||
<path d="M32 27 C38 25 43 20 46 14 C39 15 34 20 32 27" />
|
||||
</g>
|
||||
<g fill="#df79bd" stroke="#8f3b75" stroke-width="1">
|
||||
<path d="M32 27 C30 22 31 17 33 12 C36 17 35 23 32 27" />
|
||||
<path d="M32 27 C28 24 25 21 21 20 C22 25 27 28 32 27" />
|
||||
<path d="M32 27 C28 28 24 31 22 35 C27 36 31 33 32 27" />
|
||||
<path d="M32 27 C35 31 39 33 44 33 C42 28 37 26 32 27" />
|
||||
<path d="M32 27 C35 23 38 19 42 17 C41 22 37 26 32 27" />
|
||||
</g>
|
||||
<circle cx="32" cy="27" r="4.4" fill="#7a2d63" stroke="#5f214d" stroke-width="1.1" />
|
||||
<circle cx="32" cy="27" r="2.2" fill="#f0b44c" stroke="none" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
@@ -0,0 +1,6 @@
|
||||
import { anchor } from "../lib/geometry.js";
|
||||
|
||||
export const topLeftLotCorner = anchor(-100.898958, 46.826799, {
|
||||
assumedNorthClockwiseDegrees: -11.25,
|
||||
});
|
||||
export const lotCorner = topLeftLotCorner;
|
||||
@@ -0,0 +1,20 @@
|
||||
import * as geo from "../lib/geometry.js";
|
||||
import { Fence, LotBoundary } from "../lib/yard-features.js";
|
||||
import { lotCorner } from "./anchors.js";
|
||||
|
||||
const lotBoundary = new LotBoundary()
|
||||
.add(geo.offset(lotCorner))
|
||||
.add(geo.offset(lotCorner), geo.east(geo.ft(144)))
|
||||
.add(geo.offset(lotCorner), geo.east(geo.ft(144)), geo.south(geo.ft(51)))
|
||||
.add(geo.offset(lotCorner), geo.south(geo.ft(51)));
|
||||
|
||||
const picketFence = new Fence("Short picket fence", {
|
||||
fenceType: "Short picket",
|
||||
}).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,57 @@
|
||||
import * as geo from "../lib/geometry.js";
|
||||
import { defineGroup, YardGridLine } from "../lib/yard-features.js";
|
||||
import { lotCorner } from "./anchors.js";
|
||||
|
||||
const GRID_SPACING_FEET = 10;
|
||||
const MIN_EAST_FEET = -20;
|
||||
const MAX_EAST_FEET = 180;
|
||||
const MIN_NORTH_FEET = -140;
|
||||
const MAX_NORTH_FEET = 20;
|
||||
|
||||
export const yardGridGroup = defineGroup("group:yard-grid", "Yard Grid");
|
||||
|
||||
function localPoint(eastFeet, northFeet) {
|
||||
return geo.offset(
|
||||
lotCorner,
|
||||
geo.east(geo.ft(eastFeet)),
|
||||
northFeet >= 0 ? geo.north(geo.ft(northFeet)) : geo.south(geo.ft(Math.abs(northFeet))),
|
||||
);
|
||||
}
|
||||
|
||||
function makeNorthLine(eastFeet) {
|
||||
return new YardGridLine(`Grid east ${eastFeet} ft`, {
|
||||
axis: "north",
|
||||
offsetFeet: eastFeet,
|
||||
spacingFeet: GRID_SPACING_FEET,
|
||||
})
|
||||
.inGroup(yardGridGroup)
|
||||
.add(localPoint(eastFeet, MIN_NORTH_FEET))
|
||||
.add(localPoint(eastFeet, MAX_NORTH_FEET));
|
||||
}
|
||||
|
||||
function makeEastLine(northFeet) {
|
||||
return new YardGridLine(`Grid north ${northFeet} ft`, {
|
||||
axis: "east",
|
||||
offsetFeet: northFeet,
|
||||
spacingFeet: GRID_SPACING_FEET,
|
||||
})
|
||||
.inGroup(yardGridGroup)
|
||||
.add(localPoint(MIN_EAST_FEET, northFeet))
|
||||
.add(localPoint(MAX_EAST_FEET, northFeet));
|
||||
}
|
||||
|
||||
function rangeByFeet(minFeet, maxFeet, spacingFeet) {
|
||||
const values = [];
|
||||
|
||||
for (let value = minFeet; value <= maxFeet; value += spacingFeet) {
|
||||
values.push(value);
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
const northLines = rangeByFeet(MIN_EAST_FEET, MAX_EAST_FEET, GRID_SPACING_FEET).map(makeNorthLine);
|
||||
const eastLines = rangeByFeet(MIN_NORTH_FEET, MAX_NORTH_FEET, GRID_SPACING_FEET).map(makeEastLine);
|
||||
|
||||
export const gridFeatures = [...northLines, ...eastLines];
|
||||
export const gridGroups = [yardGridGroup];
|
||||
@@ -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,288 @@
|
||||
import { Daylily, defineGroup, Flower, FlowerBed, Tree } from "../lib/yard-features.js";
|
||||
import * as geo from "../lib/geometry.js";
|
||||
import { lotCorner } from "./anchors.js";
|
||||
|
||||
export const juniperTreesGroup = defineGroup("group:juniper-trees", "Juniper Trees");
|
||||
|
||||
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 daylilies = [
|
||||
new Daylily("Not sure", {
|
||||
gardenOrgId: "4765",
|
||||
}).add(geo.offset(lotCorner), geo.east(geo.ft(112)), geo.south(geo.ft(2))),
|
||||
new Daylily("Wayside King Royale", {
|
||||
gardenOrgId: "5090",
|
||||
}).add(geo.offset(lotCorner), geo.east(geo.ft(112)), geo.south(geo.ft(4))),
|
||||
new Daylily("Joan Derifield", {
|
||||
gardenOrgId: "4765",
|
||||
}).add(geo.offset(lotCorner), geo.east(geo.ft(112)), geo.south(geo.ft(6))),
|
||||
new Daylily("Predatory Flamingo", {
|
||||
gardenOrgId: "61201",
|
||||
}).add(geo.offset(lotCorner), geo.east(geo.ft(112)), geo.south(geo.ft(8))),
|
||||
new Daylily("Unknown", {
|
||||
}).add(geo.offset(lotCorner), geo.east(geo.ft(109)), geo.south(geo.ft(7))),
|
||||
new Daylily("Unknown", {
|
||||
}).add(geo.offset(lotCorner), geo.east(geo.ft(109)), geo.south(geo.ft(9))),
|
||||
new Daylily("Unknown", {
|
||||
}).add(geo.offset(lotCorner), geo.east(geo.ft(107)), geo.south(geo.ft(8))),
|
||||
new Daylily("Unknown", {
|
||||
}).add(geo.offset(lotCorner), geo.east(geo.ft(107)), geo.south(geo.ft(10))),
|
||||
new Daylily("Orange Daylily", {
|
||||
gardenOrgId: "48484",
|
||||
}).add(geo.offset(lotCorner), geo.east(geo.ft(142)), geo.south(geo.ft(24))),
|
||||
new Daylily("Orange Daylily", {
|
||||
gardenOrgId: "48484",
|
||||
}).add(geo.offset(lotCorner), geo.east(geo.ft(142)), geo.south(geo.ft(26))),
|
||||
new Daylily("Orange Daylily", {
|
||||
gardenOrgId: "48484",
|
||||
}).add(geo.offset(lotCorner), geo.east(geo.ft(142)), geo.south(geo.ft(28))),
|
||||
new Daylily("Orange Daylily", {
|
||||
gardenOrgId: "48484",
|
||||
}).add(geo.offset(lotCorner), geo.east(geo.ft(142)), geo.south(geo.ft(30))),
|
||||
new Daylily("Orange Daylily", {
|
||||
gardenOrgId: "48484",
|
||||
}).add(geo.offset(lotCorner), geo.east(geo.ft(142)), geo.south(geo.ft(32))),
|
||||
new Daylily("Orange Daylily", {
|
||||
gardenOrgId: "48484",
|
||||
}).add(geo.offset(lotCorner), geo.east(geo.ft(142)), geo.south(geo.ft(34))),
|
||||
new Daylily("Orange Daylily", {
|
||||
gardenOrgId: "48484",
|
||||
}).add(geo.offset(lotCorner), geo.east(geo.ft(142)), geo.south(geo.ft(36))),
|
||||
new Daylily("Orange Daylily", {
|
||||
gardenOrgId: "48484",
|
||||
}).add(geo.offset(lotCorner), geo.east(geo.ft(142)), geo.south(geo.ft(38))),
|
||||
new Daylily("You Had Me at Hello", {
|
||||
gardenOrgId: "235117",
|
||||
}).add(geo.offset(lotCorner), geo.east(geo.ft(28)), geo.south(geo.ft(45))),
|
||||
new Daylily("Mapping North Dakota", {
|
||||
gardenOrgId: "56003",
|
||||
}).add(geo.offset(lotCorner), geo.east(geo.ft(28)), geo.south(geo.ft(47))),
|
||||
new Daylily("Daylily").add(geo.offset(lotCorner), geo.east(geo.ft(31)), geo.south(geo.ft(47))),
|
||||
];
|
||||
|
||||
const flowers = [
|
||||
new Flower("Purple Coneflower", {
|
||||
gardenOrgId: "71445",
|
||||
plantedDate: "2024-05-01",
|
||||
}).add(geo.offset(lotCorner), geo.east(geo.ft(111.5)), geo.south(geo.ft(15))),
|
||||
new Flower("Purple Coneflower", {
|
||||
gardenOrgId: "71445",
|
||||
plantedDate: "2024-05-01",
|
||||
}).add(geo.offset(lotCorner), geo.east(geo.ft(111.5)), geo.south(geo.ft(17))),
|
||||
new Flower("Purple Coneflower", {
|
||||
gardenOrgId: "71445",
|
||||
plantedDate: "2024-05-01",
|
||||
}).add(geo.offset(lotCorner), geo.east(geo.ft(112.5)), geo.south(geo.ft(16))),
|
||||
new Flower("Purple Coneflower", {
|
||||
gardenOrgId: "71445",
|
||||
plantedDate: "2024-05-01",
|
||||
}).add(geo.offset(lotCorner), geo.east(geo.ft(112.5)), geo.south(geo.ft(14))),
|
||||
new Flower("Tiny Tortuga Turtlehead", {
|
||||
gardenOrgId: "697986",
|
||||
plantedDate: "2021-09-21",
|
||||
}).add(geo.offset(lotCorner), geo.east(geo.ft(3)), geo.south(geo.ft(8))),
|
||||
new Flower("Tiny Tortuga Turtlehead", {
|
||||
gardenOrgId: "697986",
|
||||
plantedDate: "2021-09-21",
|
||||
}).add(geo.offset(lotCorner), geo.east(geo.ft(3)), geo.south(geo.ft(17))),
|
||||
new Flower("Peony", {
|
||||
gardenOrgId: "697986",
|
||||
}).add(geo.offset(lotCorner), geo.east(geo.ft(19)), geo.south(geo.ft(1.5))),
|
||||
new Flower("Peony", {
|
||||
gardenOrgId: "697986",
|
||||
}).add(geo.offset(lotCorner), geo.east(geo.ft(75)), geo.south(geo.ft(2))),
|
||||
];
|
||||
|
||||
const houseplants = [
|
||||
new Flower("Variegata", {
|
||||
gardenOrgId: "126796",
|
||||
}).add(geo.offset(lotCorner), geo.east(geo.ft(60)), geo.south(geo.ft(28))),
|
||||
]
|
||||
|
||||
const cutDownTrees = [
|
||||
new Tree("Maybe elm", {
|
||||
canopyDiameterMeters: 15,
|
||||
heightMeters: 15,
|
||||
removedDate: "2021-05-05",
|
||||
}).add(geo.offset(lotCorner), geo.east(geo.ft(65)), geo.south(geo.ft(-2))),
|
||||
new Tree("Big pine tree", {
|
||||
canopyDiameterMeters: 8,
|
||||
heightMeters: 15,
|
||||
removedDate: "2021-05-06",
|
||||
}).add(geo.offset(lotCorner), geo.east(geo.ft(98)), geo.south(geo.ft(-15))),
|
||||
new Tree("Walnut by house", {
|
||||
canopyDiameterMeters: 6,
|
||||
heightMeters: 7,
|
||||
removedDate: "2021-05-01",
|
||||
}).add(geo.offset(lotCorner), geo.east(geo.ft(32)), geo.south(geo.ft(40))),
|
||||
new Tree("Walnut by shed", {
|
||||
canopyDiameterMeters: 7,
|
||||
heightMeters: 7,
|
||||
removedDate: "2021-05-01",
|
||||
}).add(geo.offset(lotCorner), geo.east(geo.ft(100)), geo.south(geo.ft(40))),
|
||||
];
|
||||
|
||||
const trees = [
|
||||
new Tree("Northern Empress Japanese Elm", {
|
||||
species: "Northern Empress Japanese Elm",
|
||||
canopyDiameterMeters: 6,
|
||||
heightMeters: 7,
|
||||
plantedDate: "2021-09-21",
|
||||
}).add(geo.offset(lotCorner), geo.east(geo.ft(9)), geo.south(geo.ft(16))),
|
||||
new Tree("Hot Wings Maple", {
|
||||
species: "Hot Wings Maple",
|
||||
canopyDiameterMeters: 5,
|
||||
heightMeters: 5,
|
||||
gardenOrgId: "536120",
|
||||
plantedDate: "2021-09-21",
|
||||
}).add(geo.offset(lotCorner), geo.east(geo.ft(60)), geo.south(geo.ft(40))),
|
||||
new Tree("Prairie Expedition American Elm", {
|
||||
species: "Prairie Expedition American Elm",
|
||||
canopyDiameterMeters: 5,
|
||||
heightMeters: 5,
|
||||
gardenOrgId: "736267",
|
||||
plantedDate: "2021-09-21",
|
||||
}).add(geo.offset(lotCorner), geo.east(geo.ft(95)), geo.south(geo.ft(15))),
|
||||
new Tree("Dwarf Korean Lilac", {
|
||||
species: "Dwarf Korean Lilac",
|
||||
canopyDiameterMeters: 1.75,
|
||||
heightMeters: 2.5,
|
||||
gardenOrgId: "79137",
|
||||
plantedDate: "2021-09-21",
|
||||
}).add(geo.offset(lotCorner), geo.east(geo.ft(3)), geo.south(geo.ft(3))),
|
||||
new Tree("Dwarf Korean Lilac", {
|
||||
species: "Dwarf Korean Lilac",
|
||||
canopyDiameterMeters: 1.75,
|
||||
heightMeters: 2.5,
|
||||
gardenOrgId: "79137",
|
||||
plantedDate: "2021-09-21",
|
||||
}).add(geo.offset(lotCorner), geo.east(geo.ft(3)), geo.south(geo.ft(20))),
|
||||
new Tree("Dwarf Korean Lilac", {
|
||||
species: "Dwarf Korean Lilac",
|
||||
canopyDiameterMeters: 1.75,
|
||||
heightMeters: 2.5,
|
||||
gardenOrgId: "79137",
|
||||
plantedDate: "2021-09-21",
|
||||
}).add(geo.offset(lotCorner), geo.east(geo.ft(3)), geo.south(geo.ft(37))),
|
||||
new Tree("Boulevard Linden", {
|
||||
species: "Boulevard Linden",
|
||||
canopyDiameterMeters: 3,
|
||||
heightMeters: 6,
|
||||
plantedDate: "2021-09-21",
|
||||
}).add(geo.lngLat(-100.8990003556445, 46.826776648309774)),
|
||||
new Tree("Boulevard Linden 2", {
|
||||
species: "Boulevard Linden",
|
||||
canopyDiameterMeters: 3,
|
||||
heightMeters: 6,
|
||||
plantedDate: "2021-09-21",
|
||||
}).add(geo.lngLat(-100.89898269567485, 46.826709328342595)),
|
||||
new Tree("Evergreen", {
|
||||
species: "Evergreen",
|
||||
canopyDiameterMeters: 8,
|
||||
heightMeters: 15,
|
||||
}).add(geo.lngLat(-100.89888268258135, 46.826680107011924)),
|
||||
];
|
||||
|
||||
const bushes = [
|
||||
new Tree("Medora Juniper 1", {
|
||||
species: "Medora Juniper",
|
||||
canopyDiameterMeters: 1.5,
|
||||
heightMeters: 2.5,
|
||||
plantedDate: "2021-09-21",
|
||||
}).inGroup(juniperTreesGroup).add(geo.lngLat(-100.89880933964611, 46.826705259550664)),
|
||||
new Tree("Medora Juniper 2", {
|
||||
species: "Medora Juniper",
|
||||
canopyDiameterMeters: 1.5,
|
||||
heightMeters: 2.5,
|
||||
plantedDate: "2021-09-21",
|
||||
}).inGroup(juniperTreesGroup).add(geo.lngLat(-100.89877004677517, 46.82670396593881)),
|
||||
new Tree("Medora Juniper 3", {
|
||||
species: "Medora Juniper",
|
||||
canopyDiameterMeters: 1.5,
|
||||
heightMeters: 2.5,
|
||||
plantedDate: "2021-09-21",
|
||||
}).inGroup(juniperTreesGroup).add(geo.lngLat(-100.898799106227, 46.82669830398086)),
|
||||
new Tree("Medora Juniper 4", {
|
||||
species: "Medora Juniper",
|
||||
canopyDiameterMeters: 1.5,
|
||||
heightMeters: 2.5,
|
||||
plantedDate: "2021-09-21",
|
||||
}).inGroup(juniperTreesGroup).add(geo.lngLat(-100.89878871410515, 46.82669251034885)),
|
||||
new Tree("Medora Juniper 5", {
|
||||
species: "Medora Juniper",
|
||||
canopyDiameterMeters: 1.5,
|
||||
heightMeters: 2.5,
|
||||
plantedDate: "2021-09-21",
|
||||
}).inGroup(juniperTreesGroup).add(geo.lngLat(-100.8987812086838, 46.82671120797719)),
|
||||
new Tree("Black Currant", {
|
||||
species: "Black Currant",
|
||||
canopyDiameterMeters: 2,
|
||||
heightMeters: 2,
|
||||
gardenOrgId: "87767",
|
||||
}).add(geo.offset(lotCorner), geo.east(geo.ft(107)), geo.south(geo.ft(3))),
|
||||
new Tree("Elderberry", {
|
||||
species: "Elderberry",
|
||||
canopyDiameterMeters: 3,
|
||||
heightMeters: 3,
|
||||
gardenOrgId: "78882",
|
||||
}).add(geo.offset(lotCorner), geo.east(geo.ft(142)), geo.south(geo.ft(52))),
|
||||
new Tree("Spirea", {
|
||||
species: "Spirea",
|
||||
canopyDiameterMeters: 1.5,
|
||||
heightMeters: 1,
|
||||
}).add(geo.offset(lotCorner), geo.east(geo.ft(70)), geo.south(geo.ft(31))),
|
||||
new Tree("Spirea", {
|
||||
species: "Spirea",
|
||||
canopyDiameterMeters: 1.5,
|
||||
heightMeters: 1,
|
||||
}).add(geo.offset(lotCorner), geo.east(geo.ft(74)), geo.south(geo.ft(31))),
|
||||
new Tree("Spirea", {
|
||||
species: "Spirea",
|
||||
canopyDiameterMeters: 1.5,
|
||||
heightMeters: 1,
|
||||
}).add(geo.offset(lotCorner), geo.east(geo.ft(78)), geo.south(geo.ft(31))),
|
||||
];
|
||||
|
||||
export const plantFeatures = [...flowerBeds, ...daylilies, ...flowers, ...houseplants, ...trees, ...bushes, ...cutDownTrees];
|
||||
export const plantGroups = [juniperTreesGroup];
|
||||
@@ -0,0 +1,23 @@
|
||||
import { collectYardData } from "../lib/yard-features.js";
|
||||
import { boundaryFeatures } from "./boundaries.js";
|
||||
import { buildingFeatures, buildingGroups } from "./buildings.js";
|
||||
import { gridFeatures, gridGroups } from "./grid.js";
|
||||
import { irrigationFeatures } from "./irrigation.js";
|
||||
import { pavementFeatures } from "./pavement.js";
|
||||
import { plantFeatures, plantGroups } from "./plants.js";
|
||||
|
||||
export const yardGeoJSON = collectYardData(
|
||||
[
|
||||
...boundaryFeatures,
|
||||
...gridFeatures,
|
||||
...buildingFeatures,
|
||||
...pavementFeatures,
|
||||
...plantFeatures,
|
||||
...irrigationFeatures,
|
||||
],
|
||||
[
|
||||
...buildingGroups,
|
||||
...gridGroups,
|
||||
...plantGroups,
|
||||
],
|
||||
);
|
||||
@@ -0,0 +1,82 @@
|
||||
const overlay = document.createElement("section");
|
||||
overlay.className = "error-overlay";
|
||||
overlay.hidden = true;
|
||||
overlay.innerHTML = `
|
||||
<div class="error-overlay-header">
|
||||
<strong>JavaScript error</strong>
|
||||
<div class="error-overlay-actions">
|
||||
<button type="button" data-action="copy">Copy</button>
|
||||
<button type="button" data-action="dismiss" aria-label="Dismiss error">Dismiss</button>
|
||||
</div>
|
||||
</div>
|
||||
<pre></pre>
|
||||
`;
|
||||
|
||||
const messageNode = overlay.querySelector("pre");
|
||||
const copyButton = overlay.querySelector("[data-action='copy']");
|
||||
const dismissButton = overlay.querySelector("[data-action='dismiss']");
|
||||
|
||||
copyButton.addEventListener("click", async () => {
|
||||
await copyText(messageNode.textContent);
|
||||
showCopyStatus();
|
||||
});
|
||||
|
||||
dismissButton.addEventListener("click", () => {
|
||||
overlay.hidden = true;
|
||||
});
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
document.body.append(overlay);
|
||||
});
|
||||
|
||||
window.addEventListener("error", (event) => {
|
||||
showError(formatErrorEvent(event));
|
||||
});
|
||||
|
||||
window.addEventListener("unhandledrejection", (event) => {
|
||||
showError(formatReason(event.reason));
|
||||
});
|
||||
|
||||
function showError(message) {
|
||||
messageNode.textContent = message;
|
||||
copyButton.textContent = "Copy";
|
||||
overlay.hidden = false;
|
||||
}
|
||||
|
||||
async function copyText(value) {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(value);
|
||||
return;
|
||||
}
|
||||
|
||||
const textArea = document.createElement("textarea");
|
||||
textArea.value = value;
|
||||
textArea.setAttribute("readonly", "");
|
||||
textArea.className = "error-overlay-copy-buffer";
|
||||
document.body.append(textArea);
|
||||
textArea.select();
|
||||
document.execCommand("copy");
|
||||
textArea.remove();
|
||||
}
|
||||
|
||||
function showCopyStatus() {
|
||||
copyButton.textContent = "Copied";
|
||||
|
||||
window.setTimeout(() => {
|
||||
copyButton.textContent = "Copy";
|
||||
}, 1400);
|
||||
}
|
||||
|
||||
function formatErrorEvent(event) {
|
||||
const location = [event.filename, event.lineno, event.colno].filter(Boolean).join(":");
|
||||
const detail = formatReason(event.error ?? event.message);
|
||||
return location ? `${detail}\n\n${location}` : detail;
|
||||
}
|
||||
|
||||
function formatReason(reason) {
|
||||
if (reason instanceof Error) {
|
||||
return reason.stack ?? `${reason.name}: ${reason.message}`;
|
||||
}
|
||||
|
||||
return String(reason);
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
export const featureTypes = {
|
||||
lotBoundary: {
|
||||
label: "Lot Boundary",
|
||||
geometryType: "Polygon",
|
||||
detailFields: {},
|
||||
style: {
|
||||
color: "#7b684d",
|
||||
weight: 2,
|
||||
dashArray: "8 5",
|
||||
fillColor: "#dccaab",
|
||||
fillOpacity: 0.08,
|
||||
},
|
||||
},
|
||||
fence: {
|
||||
label: "Fence",
|
||||
geometryType: "LineString",
|
||||
detailFields: {
|
||||
fenceType: "string",
|
||||
},
|
||||
style: {
|
||||
color: "#705033",
|
||||
weight: 4,
|
||||
opacity: 0.95,
|
||||
},
|
||||
},
|
||||
yardGrid: {
|
||||
label: "Yard Grid",
|
||||
geometryType: "LineString",
|
||||
detailFields: {
|
||||
axis: "string",
|
||||
offsetFeet: "number",
|
||||
spacingFeet: "number",
|
||||
},
|
||||
style: (details) => ({
|
||||
color: details.offsetFeet === 0 ? "#1f4f63" : "#2f7f98",
|
||||
weight: details.offsetFeet === 0 ? 3 : 2,
|
||||
opacity: details.offsetFeet === 0 ? 0.75 : 0.55,
|
||||
dashArray: details.offsetFeet === 0 ? undefined : "6 5",
|
||||
}),
|
||||
},
|
||||
house: {
|
||||
label: "House",
|
||||
geometryType: "Polygon",
|
||||
detailFields: {},
|
||||
style: {
|
||||
color: "#8d5a35",
|
||||
weight: 2,
|
||||
fillColor: "#c98d58",
|
||||
fillOpacity: 0.35,
|
||||
},
|
||||
},
|
||||
garage: {
|
||||
label: "Garage",
|
||||
geometryType: "Polygon",
|
||||
detailFields: {},
|
||||
style: {
|
||||
color: "#60636b",
|
||||
weight: 2,
|
||||
fillColor: "#a4acb9",
|
||||
fillOpacity: 0.45,
|
||||
},
|
||||
},
|
||||
porch: {
|
||||
label: "Porch",
|
||||
geometryType: "Polygon",
|
||||
detailFields: {},
|
||||
style: {
|
||||
color: "#aa7148",
|
||||
weight: 2,
|
||||
fillColor: "#deb588",
|
||||
fillOpacity: 0.38,
|
||||
},
|
||||
},
|
||||
deck: {
|
||||
label: "Deck",
|
||||
geometryType: "Polygon",
|
||||
detailFields: {},
|
||||
style: {
|
||||
color: "#7f5a3c",
|
||||
weight: 2,
|
||||
fillColor: "#b98c66",
|
||||
fillOpacity: 0.32,
|
||||
},
|
||||
},
|
||||
walkway: {
|
||||
label: "Walkway",
|
||||
geometryType: "Polygon",
|
||||
detailFields: {},
|
||||
style: {
|
||||
color: "#b8b3a6",
|
||||
weight: 2,
|
||||
fillColor: "#d6d1c3",
|
||||
fillOpacity: 0.75,
|
||||
},
|
||||
},
|
||||
driveway: {
|
||||
label: "Driveway",
|
||||
geometryType: "Polygon",
|
||||
detailFields: {},
|
||||
style: {
|
||||
color: "#9c9890",
|
||||
weight: 2,
|
||||
fillColor: "#bbb7af",
|
||||
fillOpacity: 0.8,
|
||||
},
|
||||
},
|
||||
flowerBed: {
|
||||
label: "Flower Bed",
|
||||
geometryType: "Polygon",
|
||||
detailFields: {
|
||||
soil: "string",
|
||||
mulch: "string",
|
||||
sunExposure: "string",
|
||||
},
|
||||
style: {
|
||||
color: "#7f7f31",
|
||||
weight: 2,
|
||||
fillColor: "#bfc97b",
|
||||
fillOpacity: 0.38,
|
||||
},
|
||||
},
|
||||
plant: {
|
||||
label: "Plant",
|
||||
geometryType: "Point",
|
||||
detailFields: {
|
||||
commonName: "string",
|
||||
genus: "string",
|
||||
species: "string",
|
||||
cultivar: "string",
|
||||
gardenOrgId: "string",
|
||||
note: "string",
|
||||
},
|
||||
externalLinks: [
|
||||
{
|
||||
label: "Garden.org",
|
||||
url: (details) =>
|
||||
details.gardenOrgId
|
||||
? `https://garden.org/plants/view/${encodeURIComponent(details.gardenOrgId)}/`
|
||||
: null,
|
||||
},
|
||||
],
|
||||
style: {
|
||||
color: "#3d7140",
|
||||
weight: 2,
|
||||
radius: 5,
|
||||
fillColor: "#78a658",
|
||||
fillOpacity: 0.8,
|
||||
},
|
||||
},
|
||||
flower: {
|
||||
label: "Flower",
|
||||
parentKind: "plant",
|
||||
geometryType: "Point",
|
||||
detailFields: {
|
||||
commonName: "string",
|
||||
genus: "string",
|
||||
species: "string",
|
||||
cultivar: "string",
|
||||
bloomColor: "string",
|
||||
bloomSeason: "string",
|
||||
note: "string",
|
||||
},
|
||||
style: {
|
||||
color: "#9c3f74",
|
||||
weight: 2,
|
||||
fillColor: "#d979aa",
|
||||
fillOpacity: 0.85,
|
||||
iconUrl: "./src/assets/icons/flower.svg",
|
||||
iconDiameterMeters: 0.4,
|
||||
},
|
||||
},
|
||||
daylily: {
|
||||
label: "Daylily",
|
||||
parentKind: "flower",
|
||||
geometryType: "Point",
|
||||
detailFields: {
|
||||
commonName: "string",
|
||||
genus: "string",
|
||||
cultivar: "string",
|
||||
bloomColor: "string",
|
||||
bloomSeason: "string",
|
||||
note: "string",
|
||||
},
|
||||
style: {
|
||||
color: "#9f5e1d",
|
||||
weight: 2,
|
||||
radius: 5,
|
||||
fillColor: "#f0b44c",
|
||||
fillOpacity: 0.88,
|
||||
iconUrl: "./src/assets/icons/daylily.svg",
|
||||
iconDiameterMeters: 2.5 / 3.280839895,
|
||||
},
|
||||
},
|
||||
shrub: {
|
||||
label: "Shrub",
|
||||
parentKind: "plant",
|
||||
geometryType: "Point",
|
||||
detailFields: {
|
||||
commonName: "string",
|
||||
genus: "string",
|
||||
species: "string",
|
||||
cultivar: "string",
|
||||
canopyDiameterMeters: "number",
|
||||
heightMeters: "number",
|
||||
note: "string",
|
||||
},
|
||||
style: (details) => ({
|
||||
color: "#426a3d",
|
||||
weight: 1,
|
||||
radiusMeters: (details.canopyDiameterMeters ?? 1) / 2,
|
||||
fillColor: "#7e9d58",
|
||||
fillOpacity: 0.62,
|
||||
}),
|
||||
},
|
||||
tree: {
|
||||
label: "Tree",
|
||||
parentKind: "plant",
|
||||
geometryType: "Point",
|
||||
detailFields: {
|
||||
commonName: "string",
|
||||
genus: "string",
|
||||
species: "string",
|
||||
cultivar: "string",
|
||||
canopyDiameterMeters: "number",
|
||||
heightMeters: "number",
|
||||
note: "string",
|
||||
},
|
||||
style: (details) => ({
|
||||
color: "#2f6b3d",
|
||||
weight: 1,
|
||||
radiusMeters: (details.canopyDiameterMeters ?? 2) / 2,
|
||||
fillColor: "#5c9e64",
|
||||
fillOpacity: 0.55,
|
||||
}),
|
||||
},
|
||||
sprinkler: {
|
||||
label: "Sprinkler",
|
||||
geometryType: "Point",
|
||||
detailFields: {
|
||||
zone: "string",
|
||||
headType: "string",
|
||||
flowRateGallonsPerMinute: "number",
|
||||
},
|
||||
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 listFeatureTypeSchemas() {
|
||||
return Object.entries(featureTypes).map(([kind, definition]) => ({
|
||||
kind,
|
||||
label: definition.label,
|
||||
parentKind: definition.parentKind ?? null,
|
||||
geometryType: definition.geometryType,
|
||||
detailFields: getInheritedDetailFields(kind),
|
||||
externalLinks: getInheritedExternalLinks(kind).map((link) => ({
|
||||
label: link.label,
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
export function resolveFeatureStyle(kind, details = {}) {
|
||||
const { style } = getFeatureType(kind);
|
||||
return typeof style === "function" ? style(details) : { ...style };
|
||||
}
|
||||
|
||||
export function resolveFeatureLinks(kind, details = {}) {
|
||||
return getInheritedExternalLinks(kind)
|
||||
.map((link) => ({
|
||||
label: link.label,
|
||||
url: link.url(details),
|
||||
}))
|
||||
.filter((link) => link.url);
|
||||
}
|
||||
|
||||
function getFeatureTypeLineage(kind) {
|
||||
const lineage = [];
|
||||
let currentKind = kind;
|
||||
|
||||
while (currentKind) {
|
||||
const definition = getFeatureType(currentKind);
|
||||
lineage.unshift(definition);
|
||||
currentKind = definition.parentKind;
|
||||
}
|
||||
|
||||
return lineage;
|
||||
}
|
||||
|
||||
function getInheritedDetailFields(kind) {
|
||||
return Object.assign(
|
||||
{},
|
||||
...getFeatureTypeLineage(kind).map((definition) => definition.detailFields ?? {}),
|
||||
);
|
||||
}
|
||||
|
||||
function getInheritedExternalLinks(kind) {
|
||||
return getFeatureTypeLineage(kind).flatMap((definition) => definition.externalLinks ?? []);
|
||||
}
|
||||
@@ -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,349 @@
|
||||
import { collectGeoJSON, offset, pointsFromLngLat } from "./geometry.js";
|
||||
import { getFeatureType, listFeatureTypeSchemas } from "./feature-types.js";
|
||||
|
||||
export const YARD_SCHEMA_VERSION = 2;
|
||||
|
||||
class YardFeature {
|
||||
constructor(kind, geometryType, name, details = {}) {
|
||||
const featureType = getFeatureType(kind);
|
||||
if (featureType.geometryType !== geometryType) {
|
||||
throw new Error(
|
||||
`${kind} features must use ${featureType.geometryType} geometry, got ${geometryType}`,
|
||||
);
|
||||
}
|
||||
|
||||
this.kind = kind;
|
||||
this.geometryType = geometryType;
|
||||
this.name = name ?? featureType.label;
|
||||
this.id = buildFeatureId(kind, this.name);
|
||||
const { plantedDate = null, removedDate = null, ...restDetails } = details;
|
||||
this.plantedDate = plantedDate;
|
||||
this.removedDate = removedDate;
|
||||
this.details = { ...restDetails };
|
||||
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;
|
||||
}
|
||||
|
||||
plantedOn(plantedDate) {
|
||||
this.plantedDate = plantedDate;
|
||||
return this;
|
||||
}
|
||||
|
||||
removedOn(removedDate) {
|
||||
this.removedDate = removedDate;
|
||||
return this;
|
||||
}
|
||||
|
||||
withLifecycleDates(plantedDate, removedDate = null) {
|
||||
this.plantedDate = plantedDate;
|
||||
this.removedDate = removedDate;
|
||||
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 properties = {
|
||||
id: this.id,
|
||||
kind: this.kind,
|
||||
label,
|
||||
name: this.name,
|
||||
plantedDate: this.plantedDate,
|
||||
removedDate: this.removedDate,
|
||||
parentId: this.parentId,
|
||||
groupIds: this.groupIds,
|
||||
details: this.details,
|
||||
};
|
||||
|
||||
if (this.geometryType === "Point") {
|
||||
const [first] = this.coordinates;
|
||||
return {
|
||||
type: "Feature",
|
||||
properties,
|
||||
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,
|
||||
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)) {
|
||||
if (rest.length === 0) {
|
||||
return first.origin;
|
||||
}
|
||||
|
||||
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 YardGridLine extends LineFeature {
|
||||
constructor(name = "Yard Grid", details = {}) {
|
||||
super("yardGrid", name, details);
|
||||
}
|
||||
}
|
||||
|
||||
export class House extends PolygonFeature {
|
||||
constructor(name = "House", details = {}) {
|
||||
super("house", name, details);
|
||||
}
|
||||
}
|
||||
|
||||
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 Plant extends PointFeature {
|
||||
constructor(name = "Plant", details = {}, kind = "plant") {
|
||||
super(kind, name, details);
|
||||
}
|
||||
}
|
||||
|
||||
export class Flower extends Plant {
|
||||
constructor(name = "Flower", details = {}, kind = "flower") {
|
||||
super(name, details, kind);
|
||||
}
|
||||
}
|
||||
|
||||
export class Daylily extends Flower {
|
||||
constructor(name = "Daylily", details = {}) {
|
||||
super(name, {
|
||||
commonName: "Daylily",
|
||||
genus: "Hemerocallis",
|
||||
...details,
|
||||
}, "daylily");
|
||||
}
|
||||
}
|
||||
|
||||
export class Shrub extends Plant {
|
||||
constructor(name = "Shrub", details = {}) {
|
||||
super(name, details, "shrub");
|
||||
}
|
||||
}
|
||||
|
||||
export class Bush extends Shrub {
|
||||
constructor(name = "Bush", details = {}) {
|
||||
super(name, details);
|
||||
}
|
||||
}
|
||||
|
||||
export class Tree extends Plant {
|
||||
constructor(name = "Tree", details = {}) {
|
||||
super(name, details, "tree");
|
||||
}
|
||||
}
|
||||
|
||||
export class Sprinkler extends PointFeature {
|
||||
constructor(name = "Sprinkler", details = {}) {
|
||||
super("sprinkler", name, details);
|
||||
}
|
||||
}
|
||||
|
||||
export function collectYardData(features, groups = []) {
|
||||
const collection = collectGeoJSON(features);
|
||||
collection.schemaVersion = YARD_SCHEMA_VERSION;
|
||||
collection.lifecycleFields = {
|
||||
plantedDate: "date",
|
||||
removedDate: "date",
|
||||
};
|
||||
collection.featureTypes = listFeatureTypeSchemas();
|
||||
collection.groups = groups.map((group) => ({
|
||||
id: group.id,
|
||||
label: group.label,
|
||||
parentGroupId: group.parentGroupId ?? null,
|
||||
}));
|
||||
return collection;
|
||||
}
|
||||
|
||||
export { collectGeoJSON };
|
||||
@@ -0,0 +1,403 @@
|
||||
import {
|
||||
Map as MapLibreMap,
|
||||
NavigationControl,
|
||||
Popup,
|
||||
addProtocol,
|
||||
} from "../vendor/maplibre/maplibre-gl.mjs";
|
||||
import { yardGeoJSON } from "./data/yard.js";
|
||||
import { resolveFeatureLinks, resolveFeatureStyle } from "./lib/feature-types.js";
|
||||
|
||||
const MAP_MAX_ZOOM = 24;
|
||||
const MANDAN_BOUNDS = [
|
||||
[-101.05, 46.74],
|
||||
[-100.75, 46.93],
|
||||
];
|
||||
const TIMELINE_PRESETS = [
|
||||
{ id: "purchase", label: "Axvigs purchase house", date: "2021-02-01" },
|
||||
{ id: "trees-2021", label: "2021 trees planted", date: "2021-09-22" },
|
||||
{ id: "today", label: "Today", date: getTodayDateString() },
|
||||
];
|
||||
|
||||
const mapElement = document.getElementById("map");
|
||||
const statusElement = document.getElementById("map-status");
|
||||
const legend = document.getElementById("legend");
|
||||
const protocol = new globalThis.pmtiles.Protocol();
|
||||
addProtocol("pmtiles", protocol.tile);
|
||||
const basemapLayers = globalThis.basemaps
|
||||
.layers("protomaps", globalThis.basemaps.namedFlavor("light"), { lang: "en" })
|
||||
.filter((layer) => layer.type !== "symbol");
|
||||
|
||||
const map = new MapLibreMap({
|
||||
container: mapElement,
|
||||
center: [-100.89875, 46.82682],
|
||||
zoom: 20,
|
||||
maxZoom: MAP_MAX_ZOOM,
|
||||
maxBounds: MANDAN_BOUNDS,
|
||||
style: {
|
||||
version: 8,
|
||||
sources: {
|
||||
protomaps: {
|
||||
type: "vector",
|
||||
url: `pmtiles://${new URL("./tiles/mandan.pmtiles", document.baseURI).href}`,
|
||||
attribution:
|
||||
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap contributors</a>',
|
||||
},
|
||||
},
|
||||
layers: basemapLayers,
|
||||
},
|
||||
});
|
||||
|
||||
map.addControl(new NavigationControl(), "bottom-right");
|
||||
|
||||
let selectedTimelineDate = TIMELINE_PRESETS.at(-1).date;
|
||||
let renderedLayerIds = [];
|
||||
let renderedSourceIds = [];
|
||||
let renderedLayerHandlers = [];
|
||||
|
||||
map.on("load", () => {
|
||||
renderYardForDate(selectedTimelineDate);
|
||||
addTimelineControl();
|
||||
map.fitBounds(getGeoJSONBounds(yardGeoJSON), {
|
||||
padding: 70,
|
||||
maxZoom: MAP_MAX_ZOOM,
|
||||
duration: 0,
|
||||
});
|
||||
});
|
||||
|
||||
map.on("sourcedata", (event) => {
|
||||
if (event.sourceId === "protomaps" && event.isSourceLoaded) {
|
||||
statusElement.hidden = true;
|
||||
}
|
||||
});
|
||||
|
||||
map.on("error", (event) => {
|
||||
const message = event.error?.message || "The vector tile archive could not be loaded.";
|
||||
statusElement.textContent = `Map unavailable: ${message}`;
|
||||
statusElement.hidden = false;
|
||||
});
|
||||
|
||||
function renderYardForDate(date) {
|
||||
removeRenderedYard();
|
||||
const features = yardGeoJSON.features.filter((feature) => featureExistsOnDate(feature, date));
|
||||
|
||||
features.forEach((feature, index) => addFeatureLayer(feature, index));
|
||||
renderLegend(features);
|
||||
syncTimelineButtons();
|
||||
}
|
||||
|
||||
function removeRenderedYard() {
|
||||
for (const { layerId, event, handler } of renderedLayerHandlers) {
|
||||
map.off(event, layerId, handler);
|
||||
}
|
||||
for (const layerId of renderedLayerIds.reverse()) {
|
||||
if (map.getLayer(layerId)) {
|
||||
map.removeLayer(layerId);
|
||||
}
|
||||
}
|
||||
for (const sourceId of renderedSourceIds) {
|
||||
if (map.getSource(sourceId)) {
|
||||
map.removeSource(sourceId);
|
||||
}
|
||||
}
|
||||
renderedLayerIds = [];
|
||||
renderedSourceIds = [];
|
||||
renderedLayerHandlers = [];
|
||||
}
|
||||
|
||||
function addFeatureLayer(feature, index) {
|
||||
const sourceId = `yard-source-${index}`;
|
||||
const layerId = `yard-layer-${index}`;
|
||||
const style = resolveFeatureStyle(feature.properties.kind, feature.properties.details);
|
||||
const layer = buildMapLibreLayer(layerId, sourceId, feature, style);
|
||||
|
||||
map.addSource(sourceId, { type: "geojson", data: feature });
|
||||
map.addLayer(layer);
|
||||
renderedSourceIds.push(sourceId);
|
||||
renderedLayerIds.push(layerId);
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
map.getCanvas().style.cursor = "pointer";
|
||||
};
|
||||
const handleMouseLeave = () => {
|
||||
map.getCanvas().style.cursor = "";
|
||||
};
|
||||
const handleClick = (event) => {
|
||||
const coordinates =
|
||||
feature.geometry.type === "Point"
|
||||
? feature.geometry.coordinates
|
||||
: [event.lngLat.lng, event.lngLat.lat];
|
||||
new Popup({ offset: 10 })
|
||||
.setLngLat(coordinates)
|
||||
.setHTML(buildPopupContent(feature.properties))
|
||||
.addTo(map);
|
||||
};
|
||||
|
||||
for (const [event, handler] of [
|
||||
["mouseenter", handleMouseEnter],
|
||||
["mouseleave", handleMouseLeave],
|
||||
["click", handleClick],
|
||||
]) {
|
||||
map.on(event, layerId, handler);
|
||||
renderedLayerHandlers.push({ layerId, event, handler });
|
||||
}
|
||||
}
|
||||
|
||||
function buildMapLibreLayer(id, source, feature, style) {
|
||||
if (feature.geometry.type === "Polygon" || feature.geometry.type === "MultiPolygon") {
|
||||
return {
|
||||
id,
|
||||
source,
|
||||
type: "fill",
|
||||
paint: {
|
||||
"fill-color": style.fillColor ?? style.color ?? "#333333",
|
||||
"fill-opacity": style.fillOpacity ?? 0.25,
|
||||
"fill-outline-color": style.color ?? "#333333",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (feature.geometry.type === "LineString" || feature.geometry.type === "MultiLineString") {
|
||||
const layer = {
|
||||
id,
|
||||
source,
|
||||
type: "line",
|
||||
paint: {
|
||||
"line-color": style.color ?? "#333333",
|
||||
"line-opacity": style.opacity ?? 1,
|
||||
"line-width": style.weight ?? 2,
|
||||
},
|
||||
};
|
||||
if (style.dashArray) {
|
||||
layer.paint["line-dasharray"] = style.dashArray.split(/\s+/).map(Number);
|
||||
}
|
||||
return layer;
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
source,
|
||||
type: "circle",
|
||||
paint: {
|
||||
"circle-color": style.fillColor ?? style.color ?? "#333333",
|
||||
"circle-opacity": style.fillOpacity ?? 0.85,
|
||||
"circle-stroke-color": style.color ?? "#333333",
|
||||
"circle-stroke-width": style.weight ?? 2,
|
||||
"circle-radius": getCircleRadius(style, feature.geometry.coordinates[1]),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function getCircleRadius(style, latitude) {
|
||||
const radiusMeters =
|
||||
style.radiusMeters ??
|
||||
(typeof style.iconDiameterMeters === "number" ? style.iconDiameterMeters / 2 : null);
|
||||
if (radiusMeters === null) {
|
||||
return style.radius ?? 5;
|
||||
}
|
||||
|
||||
const metersPerPixelAtZoomZero = 156543.03392 * Math.cos((latitude * Math.PI) / 180);
|
||||
return [
|
||||
"interpolate",
|
||||
["exponential", 2],
|
||||
["zoom"],
|
||||
15,
|
||||
(radiusMeters * 2 ** 15) / metersPerPixelAtZoomZero,
|
||||
MAP_MAX_ZOOM,
|
||||
(radiusMeters * 2 ** MAP_MAX_ZOOM) / metersPerPixelAtZoomZero,
|
||||
];
|
||||
}
|
||||
|
||||
function featureExistsOnDate(feature, date) {
|
||||
const { plantedDate, removedDate } = feature.properties;
|
||||
return (!plantedDate || plantedDate <= date) && (!removedDate || date <= removedDate);
|
||||
}
|
||||
|
||||
const timelineButtons = new globalThis.Map();
|
||||
|
||||
function addTimelineControl() {
|
||||
class TimelineControl {
|
||||
onAdd() {
|
||||
const container = document.createElement("div");
|
||||
container.className = "maplibregl-ctrl timeline-control";
|
||||
|
||||
const title = document.createElement("div");
|
||||
title.className = "timeline-control-title";
|
||||
title.textContent = "Timeline";
|
||||
|
||||
const buttonGroup = document.createElement("div");
|
||||
buttonGroup.className = "timeline-buttons";
|
||||
buttonGroup.setAttribute("role", "group");
|
||||
buttonGroup.setAttribute("aria-label", "Choose map date");
|
||||
|
||||
for (const preset of TIMELINE_PRESETS) {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.textContent = preset.label;
|
||||
button.title = preset.date;
|
||||
button.addEventListener("click", () => {
|
||||
selectedTimelineDate = preset.date;
|
||||
renderYardForDate(selectedTimelineDate);
|
||||
});
|
||||
timelineButtons.set(preset.id, button);
|
||||
buttonGroup.append(button);
|
||||
}
|
||||
|
||||
container.append(title, buttonGroup);
|
||||
return container;
|
||||
}
|
||||
|
||||
onRemove() {}
|
||||
}
|
||||
|
||||
map.addControl(new TimelineControl(), "top-right");
|
||||
syncTimelineButtons();
|
||||
}
|
||||
|
||||
function syncTimelineButtons() {
|
||||
for (const preset of TIMELINE_PRESETS) {
|
||||
const button = timelineButtons.get(preset.id);
|
||||
if (!button) continue;
|
||||
const isSelected = preset.date === selectedTimelineDate;
|
||||
button.classList.toggle("is-selected", isSelected);
|
||||
button.setAttribute("aria-pressed", String(isSelected));
|
||||
}
|
||||
}
|
||||
|
||||
function renderLegend(features) {
|
||||
const summary = new globalThis.Map();
|
||||
|
||||
for (const feature of features) {
|
||||
const key = feature.properties.kind;
|
||||
const style = resolveFeatureStyle(key, feature.properties.details);
|
||||
const existing = summary.get(key) ?? {
|
||||
label: feature.properties.label,
|
||||
count: 0,
|
||||
color: style.fillColor ?? style.color ?? "#333333",
|
||||
};
|
||||
existing.count += 1;
|
||||
summary.set(key, existing);
|
||||
}
|
||||
|
||||
legend.replaceChildren(
|
||||
...[...summary.values()].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 getTodayDateString(date = new Date()) {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
function getGeoJSONBounds(collection) {
|
||||
let west = Infinity;
|
||||
let south = Infinity;
|
||||
let east = -Infinity;
|
||||
let north = -Infinity;
|
||||
|
||||
visitCoordinates(collection.features.map((feature) => feature.geometry.coordinates), ([lon, lat]) => {
|
||||
west = Math.min(west, lon);
|
||||
south = Math.min(south, lat);
|
||||
east = Math.max(east, lon);
|
||||
north = Math.max(north, lat);
|
||||
});
|
||||
return [
|
||||
[west, south],
|
||||
[east, north],
|
||||
];
|
||||
}
|
||||
|
||||
function visitCoordinates(coordinates, callback) {
|
||||
if (typeof coordinates[0] === "number") {
|
||||
callback(coordinates);
|
||||
return;
|
||||
}
|
||||
for (const coordinate of coordinates) {
|
||||
visitCoordinates(coordinate, callback);
|
||||
}
|
||||
}
|
||||
|
||||
function buildPopupContent(properties) {
|
||||
const { kind, label, name, plantedDate, removedDate, details, parentId, groupIds } = properties;
|
||||
const entries = Object.entries(details ?? {}).filter(
|
||||
([key, value]) => value !== null && value !== undefined && shouldShowDetailField(key),
|
||||
);
|
||||
const lifecycleEntries = [];
|
||||
const relationshipEntries = [];
|
||||
const linkEntries = resolveFeatureLinks(kind, details);
|
||||
|
||||
if (plantedDate) lifecycleEntries.push(["plantedDate", plantedDate]);
|
||||
if (removedDate) lifecycleEntries.push(["removedDate", removedDate]);
|
||||
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 = [...lifecycleEntries, ...entries, ...relationshipEntries];
|
||||
const detailRows = allEntries
|
||||
.map(
|
||||
([key, value]) =>
|
||||
`<div><span>${escapeHtml(formatDetailLabel(key))}:</span> ${escapeHtml(formatDetailValue(key, value))}</div>`,
|
||||
)
|
||||
.join("");
|
||||
const linkRows = linkEntries
|
||||
.map(
|
||||
(link) =>
|
||||
`<div><a href="${escapeHtml(link.url)}" target="_blank" rel="noreferrer">${escapeHtml(link.label)}</a></div>`,
|
||||
)
|
||||
.join("");
|
||||
return `${title}${subtitle}<div>${detailRows}${linkRows}</div>`;
|
||||
}
|
||||
|
||||
function shouldShowDetailField(key) {
|
||||
return !key.endsWith("Url") && key !== "gardenOrgId";
|
||||
}
|
||||
|
||||
function formatDetailLabel(key) {
|
||||
const labels = {
|
||||
canopyDiameterMeters: "Canopy diameter",
|
||||
heightMeters: "Height",
|
||||
plantedDate: "Planted",
|
||||
removedDate: "Removed",
|
||||
commonName: "Common name",
|
||||
genus: "Genus",
|
||||
cultivar: "Cultivar",
|
||||
bloomColor: "Bloom color",
|
||||
bloomSeason: "Bloom season",
|
||||
species: "Species",
|
||||
fenceType: "Fence type",
|
||||
axis: "Axis",
|
||||
offsetFeet: "Offset",
|
||||
spacingFeet: "Spacing",
|
||||
note: "Note",
|
||||
parentFeature: "Parent",
|
||||
groups: "Groups",
|
||||
};
|
||||
return labels[key] ?? key;
|
||||
}
|
||||
|
||||
function formatDetailValue(key, value) {
|
||||
if (key === "canopyDiameterMeters" || key === "heightMeters") return `${value} m`;
|
||||
if (key === "offsetFeet" || key === "spacingFeet") return `${value} ft`;
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value)
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
: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,
|
||||
.map-frame {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.map-frame {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#map {
|
||||
min-height: calc(100vh - 2rem);
|
||||
border-radius: 28px;
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.maplibregl-popup-content {
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.maplibregl-popup-content {
|
||||
font-family: "Iowan Old Style", "Palatino Linotype", "URW Palladio L", serif;
|
||||
}
|
||||
|
||||
.map-status {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
top: 1rem;
|
||||
left: 50%;
|
||||
margin: 0;
|
||||
transform: translateX(-50%);
|
||||
padding: 0.55rem 0.8rem;
|
||||
border-radius: 8px;
|
||||
background: rgba(253, 250, 240, 0.94);
|
||||
box-shadow: 0 8px 24px rgba(60, 44, 22, 0.16);
|
||||
}
|
||||
|
||||
.map-status[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.timeline-control {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
min-width: 12rem;
|
||||
padding: 0.7rem;
|
||||
border: 1px solid var(--panel-border);
|
||||
border-radius: 8px;
|
||||
background: rgba(253, 250, 240, 0.94);
|
||||
box-shadow: 0 12px 36px rgba(60, 44, 22, 0.16);
|
||||
color: var(--ink);
|
||||
font-family: "Iowan Old Style", "Palatino Linotype", "URW Palladio L", serif;
|
||||
}
|
||||
|
||||
.timeline-control-title {
|
||||
font-size: 0.76rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.timeline-buttons {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.timeline-buttons button {
|
||||
width: 100%;
|
||||
border: 1px solid rgba(70, 107, 69, 0.28);
|
||||
border-radius: 6px;
|
||||
padding: 0.42rem 0.55rem;
|
||||
background: #fffdf6;
|
||||
color: var(--ink);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.timeline-buttons button:hover,
|
||||
.timeline-buttons button:focus-visible {
|
||||
border-color: rgba(70, 107, 69, 0.58);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.timeline-buttons button.is-selected {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.error-overlay {
|
||||
position: fixed;
|
||||
right: 1rem;
|
||||
bottom: 1rem;
|
||||
z-index: 10000;
|
||||
width: min(42rem, calc(100vw - 2rem));
|
||||
max-height: min(28rem, calc(100vh - 2rem));
|
||||
overflow: auto;
|
||||
border: 1px solid rgba(111, 23, 23, 0.32);
|
||||
border-radius: 8px;
|
||||
background: #fff7f4;
|
||||
box-shadow: 0 18px 60px rgba(59, 20, 20, 0.22);
|
||||
color: #331514;
|
||||
}
|
||||
|
||||
.error-overlay[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.error-overlay-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.8rem 1rem;
|
||||
border-bottom: 1px solid rgba(111, 23, 23, 0.18);
|
||||
background: #f6d5ce;
|
||||
}
|
||||
|
||||
.error-overlay-actions {
|
||||
display: flex;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.error-overlay button {
|
||||
border: 1px solid rgba(111, 23, 23, 0.28);
|
||||
border-radius: 6px;
|
||||
padding: 0.35rem 0.55rem;
|
||||
background: #fffaf8;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.error-overlay pre {
|
||||
margin: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.error-overlay-copy-buffer {
|
||||
position: fixed;
|
||||
top: -100vh;
|
||||
left: -100vw;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.app-shell {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
#map {
|
||||
min-height: 65vh;
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 696 B |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 618 B |
@@ -0,0 +1,661 @@
|
||||
/* required styles */
|
||||
|
||||
.leaflet-pane,
|
||||
.leaflet-tile,
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow,
|
||||
.leaflet-tile-container,
|
||||
.leaflet-pane > svg,
|
||||
.leaflet-pane > canvas,
|
||||
.leaflet-zoom-box,
|
||||
.leaflet-image-layer,
|
||||
.leaflet-layer {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
}
|
||||
.leaflet-container {
|
||||
overflow: hidden;
|
||||
}
|
||||
.leaflet-tile,
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow {
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
/* Prevents IE11 from highlighting tiles in blue */
|
||||
.leaflet-tile::selection {
|
||||
background: transparent;
|
||||
}
|
||||
/* Safari renders non-retina tile on retina better with this, but Chrome is worse */
|
||||
.leaflet-safari .leaflet-tile {
|
||||
image-rendering: -webkit-optimize-contrast;
|
||||
}
|
||||
/* hack that prevents hw layers "stretching" when loading new tiles */
|
||||
.leaflet-safari .leaflet-tile-container {
|
||||
width: 1600px;
|
||||
height: 1600px;
|
||||
-webkit-transform-origin: 0 0;
|
||||
}
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow {
|
||||
display: block;
|
||||
}
|
||||
/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */
|
||||
/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */
|
||||
.leaflet-container .leaflet-overlay-pane svg {
|
||||
max-width: none !important;
|
||||
max-height: none !important;
|
||||
}
|
||||
.leaflet-container .leaflet-marker-pane img,
|
||||
.leaflet-container .leaflet-shadow-pane img,
|
||||
.leaflet-container .leaflet-tile-pane img,
|
||||
.leaflet-container img.leaflet-image-layer,
|
||||
.leaflet-container .leaflet-tile {
|
||||
max-width: none !important;
|
||||
max-height: none !important;
|
||||
width: auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.leaflet-container img.leaflet-tile {
|
||||
/* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */
|
||||
mix-blend-mode: plus-lighter;
|
||||
}
|
||||
|
||||
.leaflet-container.leaflet-touch-zoom {
|
||||
-ms-touch-action: pan-x pan-y;
|
||||
touch-action: pan-x pan-y;
|
||||
}
|
||||
.leaflet-container.leaflet-touch-drag {
|
||||
-ms-touch-action: pinch-zoom;
|
||||
/* Fallback for FF which doesn't support pinch-zoom */
|
||||
touch-action: none;
|
||||
touch-action: pinch-zoom;
|
||||
}
|
||||
.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom {
|
||||
-ms-touch-action: none;
|
||||
touch-action: none;
|
||||
}
|
||||
.leaflet-container {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.leaflet-container a {
|
||||
-webkit-tap-highlight-color: rgba(51, 181, 229, 0.4);
|
||||
}
|
||||
.leaflet-tile {
|
||||
filter: inherit;
|
||||
visibility: hidden;
|
||||
}
|
||||
.leaflet-tile-loaded {
|
||||
visibility: inherit;
|
||||
}
|
||||
.leaflet-zoom-box {
|
||||
width: 0;
|
||||
height: 0;
|
||||
-moz-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
z-index: 800;
|
||||
}
|
||||
/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */
|
||||
.leaflet-overlay-pane svg {
|
||||
-moz-user-select: none;
|
||||
}
|
||||
|
||||
.leaflet-pane { z-index: 400; }
|
||||
|
||||
.leaflet-tile-pane { z-index: 200; }
|
||||
.leaflet-overlay-pane { z-index: 400; }
|
||||
.leaflet-shadow-pane { z-index: 500; }
|
||||
.leaflet-marker-pane { z-index: 600; }
|
||||
.leaflet-tooltip-pane { z-index: 650; }
|
||||
.leaflet-popup-pane { z-index: 700; }
|
||||
|
||||
.leaflet-map-pane canvas { z-index: 100; }
|
||||
.leaflet-map-pane svg { z-index: 200; }
|
||||
|
||||
.leaflet-vml-shape {
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
}
|
||||
.lvml {
|
||||
behavior: url(#default#VML);
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
|
||||
/* control positioning */
|
||||
|
||||
.leaflet-control {
|
||||
position: relative;
|
||||
z-index: 800;
|
||||
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
|
||||
pointer-events: auto;
|
||||
}
|
||||
.leaflet-top,
|
||||
.leaflet-bottom {
|
||||
position: absolute;
|
||||
z-index: 1000;
|
||||
pointer-events: none;
|
||||
}
|
||||
.leaflet-top {
|
||||
top: 0;
|
||||
}
|
||||
.leaflet-right {
|
||||
right: 0;
|
||||
}
|
||||
.leaflet-bottom {
|
||||
bottom: 0;
|
||||
}
|
||||
.leaflet-left {
|
||||
left: 0;
|
||||
}
|
||||
.leaflet-control {
|
||||
float: left;
|
||||
clear: both;
|
||||
}
|
||||
.leaflet-right .leaflet-control {
|
||||
float: right;
|
||||
}
|
||||
.leaflet-top .leaflet-control {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.leaflet-bottom .leaflet-control {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.leaflet-left .leaflet-control {
|
||||
margin-left: 10px;
|
||||
}
|
||||
.leaflet-right .leaflet-control {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
|
||||
/* zoom and fade animations */
|
||||
|
||||
.leaflet-fade-anim .leaflet-popup {
|
||||
opacity: 0;
|
||||
-webkit-transition: opacity 0.2s linear;
|
||||
-moz-transition: opacity 0.2s linear;
|
||||
transition: opacity 0.2s linear;
|
||||
}
|
||||
.leaflet-fade-anim .leaflet-map-pane .leaflet-popup {
|
||||
opacity: 1;
|
||||
}
|
||||
.leaflet-zoom-animated {
|
||||
-webkit-transform-origin: 0 0;
|
||||
-ms-transform-origin: 0 0;
|
||||
transform-origin: 0 0;
|
||||
}
|
||||
svg.leaflet-zoom-animated {
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.leaflet-zoom-anim .leaflet-zoom-animated {
|
||||
-webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||
-moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||
transition: transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||
}
|
||||
.leaflet-zoom-anim .leaflet-tile,
|
||||
.leaflet-pan-anim .leaflet-tile {
|
||||
-webkit-transition: none;
|
||||
-moz-transition: none;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.leaflet-zoom-anim .leaflet-zoom-hide {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
|
||||
/* cursors */
|
||||
|
||||
.leaflet-interactive {
|
||||
cursor: pointer;
|
||||
}
|
||||
.leaflet-grab {
|
||||
cursor: -webkit-grab;
|
||||
cursor: -moz-grab;
|
||||
cursor: grab;
|
||||
}
|
||||
.leaflet-crosshair,
|
||||
.leaflet-crosshair .leaflet-interactive {
|
||||
cursor: crosshair;
|
||||
}
|
||||
.leaflet-popup-pane,
|
||||
.leaflet-control {
|
||||
cursor: auto;
|
||||
}
|
||||
.leaflet-dragging .leaflet-grab,
|
||||
.leaflet-dragging .leaflet-grab .leaflet-interactive,
|
||||
.leaflet-dragging .leaflet-marker-draggable {
|
||||
cursor: move;
|
||||
cursor: -webkit-grabbing;
|
||||
cursor: -moz-grabbing;
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
/* marker & overlays interactivity */
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow,
|
||||
.leaflet-image-layer,
|
||||
.leaflet-pane > svg path,
|
||||
.leaflet-tile-container {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.leaflet-marker-icon.leaflet-interactive,
|
||||
.leaflet-image-layer.leaflet-interactive,
|
||||
.leaflet-pane > svg path.leaflet-interactive,
|
||||
svg.leaflet-image-layer.leaflet-interactive path {
|
||||
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* visual tweaks */
|
||||
|
||||
.leaflet-container {
|
||||
background: #ddd;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.leaflet-container a {
|
||||
color: #0078A8;
|
||||
}
|
||||
.leaflet-zoom-box {
|
||||
border: 2px dotted #38f;
|
||||
background: rgba(255,255,255,0.5);
|
||||
}
|
||||
|
||||
|
||||
/* general typography */
|
||||
.leaflet-container {
|
||||
font-family: "Helvetica Neue", Arial, Helvetica, sans-serif;
|
||||
font-size: 12px;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
|
||||
/* general toolbar styles */
|
||||
|
||||
.leaflet-bar {
|
||||
box-shadow: 0 1px 5px rgba(0,0,0,0.65);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.leaflet-bar a {
|
||||
background-color: #fff;
|
||||
border-bottom: 1px solid #ccc;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
line-height: 26px;
|
||||
display: block;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
color: black;
|
||||
}
|
||||
.leaflet-bar a,
|
||||
.leaflet-control-layers-toggle {
|
||||
background-position: 50% 50%;
|
||||
background-repeat: no-repeat;
|
||||
display: block;
|
||||
}
|
||||
.leaflet-bar a:hover,
|
||||
.leaflet-bar a:focus {
|
||||
background-color: #f4f4f4;
|
||||
}
|
||||
.leaflet-bar a:first-child {
|
||||
border-top-left-radius: 4px;
|
||||
border-top-right-radius: 4px;
|
||||
}
|
||||
.leaflet-bar a:last-child {
|
||||
border-bottom-left-radius: 4px;
|
||||
border-bottom-right-radius: 4px;
|
||||
border-bottom: none;
|
||||
}
|
||||
.leaflet-bar a.leaflet-disabled {
|
||||
cursor: default;
|
||||
background-color: #f4f4f4;
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
.leaflet-touch .leaflet-bar a {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
line-height: 30px;
|
||||
}
|
||||
.leaflet-touch .leaflet-bar a:first-child {
|
||||
border-top-left-radius: 2px;
|
||||
border-top-right-radius: 2px;
|
||||
}
|
||||
.leaflet-touch .leaflet-bar a:last-child {
|
||||
border-bottom-left-radius: 2px;
|
||||
border-bottom-right-radius: 2px;
|
||||
}
|
||||
|
||||
/* zoom control */
|
||||
|
||||
.leaflet-control-zoom-in,
|
||||
.leaflet-control-zoom-out {
|
||||
font: bold 18px 'Lucida Console', Monaco, monospace;
|
||||
text-indent: 1px;
|
||||
}
|
||||
|
||||
.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
|
||||
/* layers control */
|
||||
|
||||
.leaflet-control-layers {
|
||||
box-shadow: 0 1px 5px rgba(0,0,0,0.4);
|
||||
background: #fff;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.leaflet-control-layers-toggle {
|
||||
background-image: url(images/layers.png);
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
.leaflet-retina .leaflet-control-layers-toggle {
|
||||
background-image: url(images/layers-2x.png);
|
||||
background-size: 26px 26px;
|
||||
}
|
||||
.leaflet-touch .leaflet-control-layers-toggle {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
.leaflet-control-layers .leaflet-control-layers-list,
|
||||
.leaflet-control-layers-expanded .leaflet-control-layers-toggle {
|
||||
display: none;
|
||||
}
|
||||
.leaflet-control-layers-expanded .leaflet-control-layers-list {
|
||||
display: block;
|
||||
position: relative;
|
||||
}
|
||||
.leaflet-control-layers-expanded {
|
||||
padding: 6px 10px 6px 6px;
|
||||
color: #333;
|
||||
background: #fff;
|
||||
}
|
||||
.leaflet-control-layers-scrollbar {
|
||||
overflow-y: scroll;
|
||||
overflow-x: hidden;
|
||||
padding-right: 5px;
|
||||
}
|
||||
.leaflet-control-layers-selector {
|
||||
margin-top: 2px;
|
||||
position: relative;
|
||||
top: 1px;
|
||||
}
|
||||
.leaflet-control-layers label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-size: 1.08333em;
|
||||
}
|
||||
.leaflet-control-layers-separator {
|
||||
height: 0;
|
||||
border-top: 1px solid #ddd;
|
||||
margin: 5px -10px 5px -6px;
|
||||
}
|
||||
|
||||
/* Default icon URLs */
|
||||
.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */
|
||||
background-image: url(images/marker-icon.png);
|
||||
}
|
||||
|
||||
|
||||
/* attribution and scale controls */
|
||||
|
||||
.leaflet-container .leaflet-control-attribution {
|
||||
background: #fff;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
margin: 0;
|
||||
}
|
||||
.leaflet-control-attribution,
|
||||
.leaflet-control-scale-line {
|
||||
padding: 0 5px;
|
||||
color: #333;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.leaflet-control-attribution a {
|
||||
text-decoration: none;
|
||||
}
|
||||
.leaflet-control-attribution a:hover,
|
||||
.leaflet-control-attribution a:focus {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.leaflet-attribution-flag {
|
||||
display: inline !important;
|
||||
vertical-align: baseline !important;
|
||||
width: 1em;
|
||||
height: 0.6669em;
|
||||
}
|
||||
.leaflet-left .leaflet-control-scale {
|
||||
margin-left: 5px;
|
||||
}
|
||||
.leaflet-bottom .leaflet-control-scale {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.leaflet-control-scale-line {
|
||||
border: 2px solid #777;
|
||||
border-top: none;
|
||||
line-height: 1.1;
|
||||
padding: 2px 5px 1px;
|
||||
white-space: nowrap;
|
||||
-moz-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
text-shadow: 1px 1px #fff;
|
||||
}
|
||||
.leaflet-control-scale-line:not(:first-child) {
|
||||
border-top: 2px solid #777;
|
||||
border-bottom: none;
|
||||
margin-top: -2px;
|
||||
}
|
||||
.leaflet-control-scale-line:not(:first-child):not(:last-child) {
|
||||
border-bottom: 2px solid #777;
|
||||
}
|
||||
|
||||
.leaflet-touch .leaflet-control-attribution,
|
||||
.leaflet-touch .leaflet-control-layers,
|
||||
.leaflet-touch .leaflet-bar {
|
||||
box-shadow: none;
|
||||
}
|
||||
.leaflet-touch .leaflet-control-layers,
|
||||
.leaflet-touch .leaflet-bar {
|
||||
border: 2px solid rgba(0,0,0,0.2);
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
|
||||
/* popup */
|
||||
|
||||
.leaflet-popup {
|
||||
position: absolute;
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.leaflet-popup-content-wrapper {
|
||||
padding: 1px;
|
||||
text-align: left;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.leaflet-popup-content {
|
||||
margin: 13px 24px 13px 20px;
|
||||
line-height: 1.3;
|
||||
font-size: 13px;
|
||||
font-size: 1.08333em;
|
||||
min-height: 1px;
|
||||
}
|
||||
.leaflet-popup-content p {
|
||||
margin: 17px 0;
|
||||
margin: 1.3em 0;
|
||||
}
|
||||
.leaflet-popup-tip-container {
|
||||
width: 40px;
|
||||
height: 20px;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
margin-top: -1px;
|
||||
margin-left: -20px;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
.leaflet-popup-tip {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
padding: 1px;
|
||||
|
||||
margin: -10px auto 0;
|
||||
pointer-events: auto;
|
||||
|
||||
-webkit-transform: rotate(45deg);
|
||||
-moz-transform: rotate(45deg);
|
||||
-ms-transform: rotate(45deg);
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
.leaflet-popup-content-wrapper,
|
||||
.leaflet-popup-tip {
|
||||
background: white;
|
||||
color: #333;
|
||||
box-shadow: 0 3px 14px rgba(0,0,0,0.4);
|
||||
}
|
||||
.leaflet-container a.leaflet-popup-close-button {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
border: none;
|
||||
text-align: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
font: 16px/24px Tahoma, Verdana, sans-serif;
|
||||
color: #757575;
|
||||
text-decoration: none;
|
||||
background: transparent;
|
||||
}
|
||||
.leaflet-container a.leaflet-popup-close-button:hover,
|
||||
.leaflet-container a.leaflet-popup-close-button:focus {
|
||||
color: #585858;
|
||||
}
|
||||
.leaflet-popup-scrolled {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.leaflet-oldie .leaflet-popup-content-wrapper {
|
||||
-ms-zoom: 1;
|
||||
}
|
||||
.leaflet-oldie .leaflet-popup-tip {
|
||||
width: 24px;
|
||||
margin: 0 auto;
|
||||
|
||||
-ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)";
|
||||
filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678);
|
||||
}
|
||||
|
||||
.leaflet-oldie .leaflet-control-zoom,
|
||||
.leaflet-oldie .leaflet-control-layers,
|
||||
.leaflet-oldie .leaflet-popup-content-wrapper,
|
||||
.leaflet-oldie .leaflet-popup-tip {
|
||||
border: 1px solid #999;
|
||||
}
|
||||
|
||||
|
||||
/* div icon */
|
||||
|
||||
.leaflet-div-icon {
|
||||
background: #fff;
|
||||
border: 1px solid #666;
|
||||
}
|
||||
|
||||
|
||||
/* Tooltip */
|
||||
/* Base styles for the element that has a tooltip */
|
||||
.leaflet-tooltip {
|
||||
position: absolute;
|
||||
padding: 6px;
|
||||
background-color: #fff;
|
||||
border: 1px solid #fff;
|
||||
border-radius: 3px;
|
||||
color: #222;
|
||||
white-space: nowrap;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
|
||||
}
|
||||
.leaflet-tooltip.leaflet-interactive {
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.leaflet-tooltip-top:before,
|
||||
.leaflet-tooltip-bottom:before,
|
||||
.leaflet-tooltip-left:before,
|
||||
.leaflet-tooltip-right:before {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
border: 6px solid transparent;
|
||||
background: transparent;
|
||||
content: "";
|
||||
}
|
||||
|
||||
/* Directions */
|
||||
|
||||
.leaflet-tooltip-bottom {
|
||||
margin-top: 6px;
|
||||
}
|
||||
.leaflet-tooltip-top {
|
||||
margin-top: -6px;
|
||||
}
|
||||
.leaflet-tooltip-bottom:before,
|
||||
.leaflet-tooltip-top:before {
|
||||
left: 50%;
|
||||
margin-left: -6px;
|
||||
}
|
||||
.leaflet-tooltip-top:before {
|
||||
bottom: 0;
|
||||
margin-bottom: -12px;
|
||||
border-top-color: #fff;
|
||||
}
|
||||
.leaflet-tooltip-bottom:before {
|
||||
top: 0;
|
||||
margin-top: -12px;
|
||||
margin-left: -6px;
|
||||
border-bottom-color: #fff;
|
||||
}
|
||||
.leaflet-tooltip-left {
|
||||
margin-left: -6px;
|
||||
}
|
||||
.leaflet-tooltip-right {
|
||||
margin-left: 6px;
|
||||
}
|
||||
.leaflet-tooltip-left:before,
|
||||
.leaflet-tooltip-right:before {
|
||||
top: 50%;
|
||||
margin-top: -6px;
|
||||
}
|
||||
.leaflet-tooltip-left:before {
|
||||
right: 0;
|
||||
margin-right: -12px;
|
||||
border-left-color: #fff;
|
||||
}
|
||||
.leaflet-tooltip-right:before {
|
||||
left: 0;
|
||||
margin-left: -12px;
|
||||
border-right-color: #fff;
|
||||
}
|
||||
|
||||
/* Printing */
|
||||
|
||||
@media print {
|
||||
/* Prevent printers from removing background-images of controls. */
|
||||
.leaflet-control {
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact;
|
||||
}
|
||||
}
|
||||