Use local PMTiles basemap
This commit is contained in:
@@ -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 };
|
||||
+403
@@ -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("'", "'");
|
||||
}
|
||||
Reference in New Issue
Block a user