Use local PMTiles basemap

This commit is contained in:
2026-07-25 21:55:35 -05:00
commit 4532a5c5dd
37 changed files with 4019 additions and 0 deletions
+45
View 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

+33
View File
@@ -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

+6
View File
@@ -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;
+20
View File
@@ -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];
+42
View File
@@ -0,0 +1,42 @@
import { Deck, defineGroup, Garage, House, Porch } from "../lib/yard-features.js";
export const housePartsGroup = defineGroup("group:house-parts", "House Parts");
const house = new House().withId("feature:house-main").trace([
[-100.89883475400929, 46.826808023423844],
[-100.8986668040935, 46.826830709973976],
[-100.89866031675769, 46.826807530237865],
[-100.89869131180652, 46.82680358474988],
[-100.89867545387456, 46.82675525249843],
[-100.89881168792644, 46.82673552503637],
]);
const garage = new Garage().trace([
[-100.89850173743805, 46.82679569377302],
[-100.89839289435957, 46.82681098253964],
[-100.89837054909181, 46.82674095008918],
[-100.8984801129854, 46.826727140862815],
]);
const porch = new Porch().childOf(house).inGroup(housePartsGroup).trace([
[-100.89886286579778, 46.8268040779359],
[-100.8988346639074, 46.8268079617756],
[-100.8988116428755, 46.8267355250364],
[-100.89875969913811, 46.826743015307995],
[-100.89875316675139, 46.826722208995406],
[-100.8988050654378, 46.82671481119343],
[-100.89881515684904, 46.8267162907539],
[-100.89882614927917, 46.8267201129516],
[-100.89883678130174, 46.82672738745619],
[-100.89884281812813, 46.82673191860895],
]);
const deck = new Deck().childOf(house).inGroup(housePartsGroup).trace([
[-100.89869090634811, 46.826803553925714],
[-100.89864495438617, 46.826809472157606],
[-100.89863017767685, 46.8267616330979],
[-100.89867522861992, 46.82675522167426],
]);
export const buildingFeatures = [house, garage, porch, deck];
export const buildingGroups = [housePartsGroup];
+57
View File
@@ -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];
+8
View File
@@ -0,0 +1,8 @@
import { east, ft, offset, south } from "../lib/geometry.js";
import { Sprinkler } from "../lib/yard-features.js";
import { lotCorner } from "./anchors.js";
export const irrigationFeatures = [
new Sprinkler("Front bed sprinkler").add(offset(lotCorner), south(ft(15)), east(ft(10))),
new Sprinkler("Garage side sprinkler").add(offset(lotCorner), south(ft(92)), east(ft(40))),
];
+51
View File
@@ -0,0 +1,51 @@
import { Driveway, Walkway } from "../lib/yard-features.js";
const frontWalk = new Walkway("Front walk").trace([
[-100.89894913835376, 46.82676927748655],
[-100.89887345276941, 46.82677975769509],
[-100.89887849847504, 46.82679985503042],
[-100.89887651623354, 46.82680417040822],
[-100.8988709299166, 46.826808732378645],
[-100.89886462278457, 46.82681082841896],
[-100.89883254651308, 46.82681514379587],
[-100.89883047416976, 46.826808562845976],
[-100.89886426237707, 46.82680492559929],
[-100.89886660502609, 46.826802891207],
[-100.89886716816289, 46.82679990126662],
[-100.89886270811955, 46.82678063618305],
[-100.89885982485919, 46.82678254727965],
[-100.89885694159882, 46.82678341035552],
[-100.89885243650448, 46.826766457077134],
[-100.89885784261767, 46.82676596389077],
[-100.89886288832331, 46.82676682696687],
[-100.89886694290817, 46.82676836817423],
[-100.8989459622624, 46.826758196205],
]);
const driveway = new Driveway().trace([
[-100.89851482473702, 46.82685818966149],
[-100.8984941013032, 46.826796541436565],
[-100.89837336477572, 46.82681355635369],
[-100.89839320971627, 46.8268766687031],
]);
const garageWalk = new Walkway("Garage walk").trace([
[-100.89862628077022, 46.82677780036217],
[-100.8986050167251, 46.82677755376906],
[-100.89853023215954, 46.82678544474858],
[-100.89851239198609, 46.82679937725647],
[-100.89850049853712, 46.82681602228328],
[-100.8984941013032, 46.826796541436565],
[-100.89850173743805, 46.82679569377302],
[-100.89849635385036, 46.82677854014156],
[-100.89862267669479, 46.82676559400101],
]);
const stepPad = new Walkway("Step pad").trace([
[-100.89837579752665, 46.82675708653554],
[-100.89835705633432, 46.826759675764286],
[-100.89835309185132, 46.82674722280572],
[-100.89837111222856, 46.82674500346631],
]);
export const pavementFeatures = [frontWalk, driveway, garageWalk, stepPad];
+288
View File
@@ -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];
+23
View File
@@ -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,
],
);
+82
View File
@@ -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);
}
+311
View File
@@ -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 ?? []);
}
+204
View File
@@ -0,0 +1,204 @@
const FEET_PER_METER = 3.280839895;
const METERS_PER_FOOT = 1 / FEET_PER_METER;
const EARTH_METERS_PER_DEGREE_LAT = 111320;
function toRadians(value) {
return (value * Math.PI) / 180;
}
function rotateLocalToTrueNorthEast(northMeters, eastMeters, assumedNorthClockwiseDegrees = 0) {
const clockwiseRadians = toRadians(assumedNorthClockwiseDegrees);
const trueNorthMeters =
northMeters * Math.cos(clockwiseRadians) - eastMeters * Math.sin(clockwiseRadians);
const trueEastMeters =
northMeters * Math.sin(clockwiseRadians) + eastMeters * Math.cos(clockwiseRadians);
return {
northMeters: trueNorthMeters,
eastMeters: trueEastMeters,
};
}
export function point(lat, lon) {
return { lat, lon };
}
export function lngLat(lon, lat) {
return point(lat, lon);
}
export function anchor(lon, lat, options = {}) {
return {
...lngLat(lon, lat),
...options,
};
}
export function pointsFromLngLat(coordinates) {
return coordinates.map(([lon, lat]) => lngLat(lon, lat));
}
export function ft(value) {
return value * METERS_PER_FOOT;
}
function isPoint(value) {
return Boolean(value) && typeof value.lat === "number" && typeof value.lon === "number";
}
function isMove(value) {
return (
Boolean(value) &&
typeof value.northMeters === "number" &&
typeof value.eastMeters === "number"
);
}
function isOffsetAnchor(value) {
return Boolean(value) && value.kind === "offset-anchor" && isPoint(value.origin);
}
function translate(origin, northMeters, eastMeters) {
const latDelta = northMeters / EARTH_METERS_PER_DEGREE_LAT;
const lonDelta = eastMeters / (EARTH_METERS_PER_DEGREE_LAT * Math.cos(toRadians(origin.lat)));
return point(origin.lat + latDelta, origin.lon + lonDelta);
}
export function offset(origin, ...moves) {
if (moves.length === 0) {
return { kind: "offset-anchor", origin };
}
const net = moves.reduce(
(accumulator, move) => ({
northMeters: accumulator.northMeters + move.northMeters,
eastMeters: accumulator.eastMeters + move.eastMeters,
}),
{ northMeters: 0, eastMeters: 0 },
);
const adjusted = rotateLocalToTrueNorthEast(
net.northMeters,
net.eastMeters,
origin.assumedNorthClockwiseDegrees ?? 0,
);
return translate(origin, adjusted.northMeters, adjusted.eastMeters);
}
export function north(distanceMeters) {
return { northMeters: distanceMeters, eastMeters: 0 };
}
export function south(distanceMeters) {
return { northMeters: -distanceMeters, eastMeters: 0 };
}
export function east(distanceMeters) {
return { northMeters: 0, eastMeters: distanceMeters };
}
export function west(distanceMeters) {
return { northMeters: 0, eastMeters: -distanceMeters };
}
function buildFeature(type, geometryType, style = {}) {
const coordinates = [];
return {
add(...values) {
coordinates.push(resolveCoordinate(values));
return this;
},
toGeoJSON() {
if (geometryType === "Point") {
const [first] = coordinates;
return {
type: "Feature",
properties: { type, style },
geometry: {
type: "Point",
coordinates: [first.lon, first.lat],
},
};
}
const ring = coordinates.map((coordinate) => [coordinate.lon, coordinate.lat]);
const geometry =
geometryType === "LineString"
? { type: "LineString", coordinates: ring }
: { type: "Polygon", coordinates: [closeRing(ring)] };
return {
type: "Feature",
properties: { type, style },
geometry,
};
},
};
}
function resolveCoordinate(values) {
if (values.length === 1 && isPoint(values[0])) {
return values[0];
}
const [first, ...rest] = values;
if (isOffsetAnchor(first) && rest.every(isMove)) {
return offset(first.origin, ...rest);
}
throw new Error("add(...) expects a point, or offset(origin) followed by directional moves");
}
function closeRing(coordinates) {
if (coordinates.length === 0) {
return coordinates;
}
const [firstLon, firstLat] = coordinates[0];
const [lastLon, lastLat] = coordinates[coordinates.length - 1];
if (firstLon === lastLon && firstLat === lastLat) {
return coordinates;
}
return [...coordinates, coordinates[0]];
}
export function polygon(type, style) {
return buildFeature(type, "Polygon", style);
}
export function path(type, style) {
return buildFeature(type, "LineString", style);
}
export function marker(type, style) {
return buildFeature(type, "Point", style);
}
export function sprinkler(name) {
return marker(name, {
color: "#2d7dd2",
radius: 5,
fillColor: "#88bdf2",
fillOpacity: 0.95,
});
}
export function tree(name) {
return marker(name, {
color: "#2f6b3d",
radius: 9,
fillColor: "#5c9e64",
fillOpacity: 0.85,
});
}
export function collectGeoJSON(...collections) {
return {
type: "FeatureCollection",
features: collections.flat().map((entry) => entry.toGeoJSON()),
};
}
+349
View File
@@ -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 };
+403
View File
@@ -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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}