Skip to content

Commit 2d01002

Browse files
authored
Merge pull request #5 from dbech/t3code/598587c3
Use fixed A-to-B path endpoints
2 parents 6bd00b0 + 5164513 commit 2d01002

6 files changed

Lines changed: 583 additions & 251 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ npm-debug.log*
3434
yarn-debug.log*
3535
yarn-error.log*
3636
.pnpm-debug.log*
37+
path-profile-dev.log
3738

3839
# local env files
3940
# do not commit any .env files to git, except for the .env.example file. https://create.t3.gg/en/usage/env-variables#using-environment-variables

README.md

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -124,10 +124,9 @@ bun run format:check
124124
2. Use `File > Open DEM...` to select one or more DEM GeoTIFF files.
125125
3. Use the floating map controls to choose DEM styling and start the path tool.
126126
4. Use `View > Basemap` to choose the background basemap.
127-
5. Draw a path over the DEM.
128-
6. Save the path to generate the elevation profile.
129-
7. Hover the profile chart or table to inspect the sampled map location.
130-
8. Use `File > Export Profile CSV` or the CSV button to export profile values.
127+
5. Click the map once to place `A`, then click again to place `B` and generate the elevation profile.
128+
6. Hover the profile chart or table to inspect the sampled map location.
129+
7. Use `File > Export Profile CSV` or the CSV button to export profile values.
131130

132131
## Notes
133132

electron/main.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ async function createWindow(): Promise<void> {
6666
height: 980,
6767
minWidth: 1040,
6868
minHeight: 720,
69+
show: false,
6970
title: "Path Profile",
7071
webPreferences: {
7172
contextIsolation: true,
@@ -83,6 +84,9 @@ async function createWindow(): Promise<void> {
8384
});
8485

8586
await loadRenderer(window);
87+
if (!window.isDestroyed()) {
88+
window.show();
89+
}
8690
}
8791

8892
async function loadRenderer(window: BrowserWindow): Promise<void> {

src/components/dsm-map.tsx

Lines changed: 148 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"use client";
22

33
import { useCallback, useEffect, useMemo, useRef } from "react";
4-
import Feature from "ol/Feature";
4+
import Feature, { type FeatureLike } from "ol/Feature";
55
import Map from "ol/Map";
66
import View from "ol/View";
77
import { defaults as defaultControls } from "ol/control/defaults";
@@ -23,8 +23,9 @@ import VectorSource from "ol/source/Vector";
2323
import XYZ from "ol/source/XYZ";
2424
import Fill from "ol/style/Fill";
2525
import CircleStyle from "ol/style/Circle";
26+
import Icon from "ol/style/Icon";
2627
import Stroke from "ol/style/Stroke";
27-
import Style from "ol/style/Style";
28+
import Style, { type StyleFunction } from "ol/style/Style";
2829
import TileGrid from "ol/tilegrid/TileGrid";
2930
import { type BasemapId, getBasemap } from "~/lib/basemaps";
3031
import type {
@@ -42,7 +43,6 @@ type DsmMapProps = {
4243
selectedBasemap: BasemapId;
4344
drawingEnabled: boolean;
4445
pathEditEnabled: boolean;
45-
finishDrawingRequest: number;
4646
clearPathRequest: number;
4747
restorePathRequest: number;
4848
pathToRestore: { coordinates: Coordinate[]; projection: string | null };
@@ -52,7 +52,6 @@ type DsmMapProps = {
5252
projection: string,
5353
changeType: "draw" | "modify",
5454
) => void;
55-
onFinishDrawingFailed: () => void;
5655
onPathContextMenu: (position: { x: number; y: number }) => void;
5756
};
5857

@@ -61,6 +60,12 @@ type DsmRenderSettings = {
6160
reverse: boolean;
6261
};
6362

63+
const endpointColors: Record<"A" | "B", string> = {
64+
A: "#157b68",
65+
B: "#c93d4b",
66+
};
67+
const endpointBadgeSize = 18;
68+
6469
const lineStyle = new Style({
6570
stroke: new Stroke({ color: "#f6c445", width: 3 }),
6671
});
@@ -77,6 +82,20 @@ const markerStyle = new Style({
7782
}),
7883
});
7984

85+
const pathEndpointStyles = [
86+
createEndpointLabelStyle("A", endpointColors.A, "start"),
87+
createEndpointLabelStyle("B", endpointColors.B, "end"),
88+
];
89+
90+
const normalPathStyles = [lineStyle, ...pathEndpointStyles];
91+
const editingPathStyles = [editLineStyle, ...pathEndpointStyles];
92+
93+
const endpointLabelStyles: Record<"A" | "B", Style> = {
94+
A: createEndpointLabelStyle("A", endpointColors.A),
95+
B: createEndpointLabelStyle("B", endpointColors.B),
96+
};
97+
98+
const hiddenModifyStyle: StyleFunction = () => undefined;
8099
const basemapProjectionCode = "EPSG:3857";
81100

82101
export function DsmMap({
@@ -85,13 +104,11 @@ export function DsmMap({
85104
selectedBasemap,
86105
drawingEnabled,
87106
pathEditEnabled,
88-
finishDrawingRequest,
89107
clearPathRequest,
90108
restorePathRequest,
91109
pathToRestore,
92110
activePoint,
93111
onDraftPathChange,
94-
onFinishDrawingFailed,
95112
onPathContextMenu,
96113
}: DsmMapProps) {
97114
const mapElementRef = useRef<HTMLDivElement | null>(null);
@@ -100,12 +117,12 @@ export function DsmMap({
100117
const basemapLayerRef = useRef<TileLayer<XYZ> | null>(null);
101118
const colorSettingsRef = useRef(colorSettings);
102119
const pathSourceRef = useRef(new VectorSource());
120+
const sketchEndpointSourceRef = useRef(new VectorSource());
103121
const markerSourceRef = useRef(new VectorSource());
104122
const drawRef = useRef<Draw | null>(null);
105123
const modifyRef = useRef<Modify | null>(null);
106124
const snapRef = useRef<Snap | null>(null);
107125
const onDraftPathChangeRef = useRef(onDraftPathChange);
108-
const onFinishDrawingFailedRef = useRef(onFinishDrawingFailed);
109126
const onPathContextMenuRef = useRef(onPathContextMenu);
110127
const lastRestorePathRequestRef = useRef(0);
111128

@@ -130,13 +147,17 @@ export function DsmMap({
130147

131148
const pathLayer = new VectorLayer({
132149
source: pathSourceRef.current,
133-
style: (feature) =>
134-
feature.get("editing") === true ? editLineStyle : lineStyle,
150+
style: pathFeatureStyle,
135151
zIndex: 20,
136152
});
137153
const markerLayer = new VectorLayer({
138154
source: markerSourceRef.current,
139155
style: markerStyle,
156+
zIndex: 40,
157+
});
158+
const sketchEndpointLayer = new VectorLayer({
159+
source: sketchEndpointSourceRef.current,
160+
style: endpointLabelStyle,
140161
zIndex: 30,
141162
});
142163
const map = new Map({
@@ -146,7 +167,7 @@ export function DsmMap({
146167
rotate: false,
147168
zoom: false,
148169
}),
149-
layers: [pathLayer, markerLayer],
170+
layers: [pathLayer, sketchEndpointLayer, markerLayer],
150171
view: new View({
151172
projection: basemapProjectionCode,
152173
center: [0, 0],
@@ -170,10 +191,6 @@ export function DsmMap({
170191
onDraftPathChangeRef.current = onDraftPathChange;
171192
}, [onDraftPathChange]);
172193

173-
useEffect(() => {
174-
onFinishDrawingFailedRef.current = onFinishDrawingFailed;
175-
}, [onFinishDrawingFailed]);
176-
177194
useEffect(() => {
178195
onPathContextMenuRef.current = onPathContextMenu;
179196
}, [onPathContextMenu]);
@@ -184,6 +201,7 @@ export function DsmMap({
184201

185202
registerProjectProjection(project);
186203
pathSourceRef.current.clear();
204+
sketchEndpointSourceRef.current.clear();
187205
markerSourceRef.current.clear();
188206

189207
if (drawRef.current) map.removeInteraction(drawRef.current);
@@ -238,35 +256,57 @@ export function DsmMap({
238256
const draw = new Draw({
239257
source: pathSourceRef.current,
240258
type: "LineString",
241-
style: editLineStyle,
259+
minPoints: 2,
260+
maxPoints: 2,
261+
style: editingPathStyles,
242262
});
243-
draw.on("drawstart", () => {
263+
draw.on("drawstart", (event) => {
244264
pathSourceRef.current.clear();
265+
sketchEndpointSourceRef.current.clear();
245266
markerSourceRef.current.clear();
267+
268+
const geometry = event.feature.getGeometry();
269+
if (geometry instanceof LineString) {
270+
const start = geometry.getCoordinates()[0] as Coordinate | undefined;
271+
if (start) {
272+
updateEndpointLabels(sketchEndpointSourceRef.current, [start]);
273+
}
274+
}
246275
});
247276
draw.on("drawend", (event) => {
248277
const geometry = event.feature.getGeometry();
249278
if (geometry instanceof LineString) {
250-
event.feature.set("path", true);
251-
onDraftPathChangeRef.current(
279+
const coordinates = straightPathEndpoints(
252280
geometry.getCoordinates() as Coordinate[],
253-
projectionCode,
254-
"draw",
255281
);
282+
if (coordinates.length < 2) return;
283+
284+
geometry.setCoordinates(coordinates);
285+
event.feature.set("path", true);
286+
sketchEndpointSourceRef.current.clear();
287+
onDraftPathChangeRef.current(coordinates, projectionCode, "draw");
256288
}
257289
});
258290
draw.setActive(false);
259291

260-
const modify = new Modify({ source: pathSourceRef.current });
292+
const modify = new Modify({
293+
source: pathSourceRef.current,
294+
insertVertexCondition: () => false,
295+
deleteCondition: () => false,
296+
pixelTolerance: 16,
297+
style: hiddenModifyStyle,
298+
});
261299
modify.on("modifyend", () => {
262300
const feature = pathSourceRef.current.getFeatures()[0];
263301
const geometry = feature?.getGeometry();
264302
if (geometry instanceof LineString) {
265-
onDraftPathChangeRef.current(
303+
const coordinates = straightPathEndpoints(
266304
geometry.getCoordinates() as Coordinate[],
267-
projectionCode,
268-
"modify",
269305
);
306+
if (coordinates.length < 2) return;
307+
308+
geometry.setCoordinates(coordinates);
309+
onDraftPathChangeRef.current(coordinates, projectionCode, "modify");
270310
}
271311
});
272312
modify.setActive(false);
@@ -289,21 +329,15 @@ export function DsmMap({
289329
}, [drawingEnabled, pathEditEnabled]);
290330

291331
useEffect(() => {
292-
if (finishDrawingRequest === 0) return;
293-
const draw = drawRef.current;
294-
if (!draw?.getActive()) return;
295-
296-
try {
297-
draw.finishDrawing();
298-
} catch {
299-
// OpenLayers throws if there are not enough points to finish the sketch.
300-
onFinishDrawingFailedRef.current();
332+
for (const feature of pathSourceRef.current.getFeatures()) {
333+
feature.set("editing", pathEditEnabled);
301334
}
302-
}, [finishDrawingRequest]);
335+
}, [pathEditEnabled]);
303336

304337
useEffect(() => {
305338
if (clearPathRequest === 0) return;
306339
pathSourceRef.current.clear();
340+
sketchEndpointSourceRef.current.clear();
307341
markerSourceRef.current.clear();
308342
}, [clearPathRequest]);
309343

@@ -312,11 +346,12 @@ export function DsmMap({
312346
if (lastRestorePathRequestRef.current === restorePathRequest) return;
313347
lastRestorePathRequestRef.current = restorePathRequest;
314348
pathSourceRef.current.clear();
349+
sketchEndpointSourceRef.current.clear();
315350
markerSourceRef.current.clear();
316351
if (pathToRestore.coordinates.length < 2) return;
317-
pathSourceRef.current.addFeature(
318-
createPathFeature(pathToRestore.coordinates),
319-
);
352+
const coordinates = straightPathEndpoints(pathToRestore.coordinates);
353+
if (coordinates.length < 2) return;
354+
pathSourceRef.current.addFeature(createPathFeature(coordinates));
320355
}, [pathToRestore, restorePathRequest]);
321356

322357
useEffect(() => {
@@ -513,6 +548,82 @@ function createPathFeature(coordinates: Coordinate[]): Feature<LineString> {
513548
});
514549
}
515550

551+
function pathFeatureStyle(feature: FeatureLike): Style[] {
552+
return feature.get("editing") === true ? editingPathStyles : normalPathStyles;
553+
}
554+
555+
function straightPathEndpoints(coordinates: Coordinate[]): Coordinate[] {
556+
const first = coordinates[0];
557+
const last = coordinates.at(-1);
558+
if (!first || !last || coordinates.length < 2) return [];
559+
560+
return [
561+
[first[0], first[1]],
562+
[last[0], last[1]],
563+
];
564+
}
565+
566+
function updateEndpointLabels(
567+
source: VectorSource,
568+
coordinates: Coordinate[],
569+
): void {
570+
source.clear();
571+
572+
const first = coordinates[0];
573+
if (first) {
574+
source.addFeature(createEndpointFeature("A", first));
575+
}
576+
577+
const last = coordinates.at(-1);
578+
if (last && coordinates.length >= 2) {
579+
source.addFeature(createEndpointFeature("B", last));
580+
}
581+
}
582+
583+
function createEndpointFeature(label: "A" | "B", coordinate: Coordinate) {
584+
const feature = new Feature({
585+
geometry: new Point(coordinate),
586+
});
587+
feature.set("endpointLabel", label);
588+
return feature;
589+
}
590+
591+
function endpointLabelStyle(feature: FeatureLike): Style {
592+
const label = feature.get("endpointLabel") === "B" ? "B" : "A";
593+
return endpointLabelStyles[label];
594+
}
595+
596+
function createEndpointLabelStyle(
597+
label: "A" | "B",
598+
color: string,
599+
endpoint?: "start" | "end",
600+
): Style {
601+
return new Style({
602+
geometry: endpoint ? endpointGeometry(endpoint) : undefined,
603+
image: new Icon({
604+
src: createEndpointBadgeDataUrl(label, color),
605+
}),
606+
});
607+
}
608+
609+
function createEndpointBadgeDataUrl(label: "A" | "B", color: string): string {
610+
const center = endpointBadgeSize / 2;
611+
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${endpointBadgeSize}" height="${endpointBadgeSize}" viewBox="0 0 ${endpointBadgeSize} ${endpointBadgeSize}"><rect width="${endpointBadgeSize}" height="${endpointBadgeSize}" rx="2" fill="${color}"/><text x="${center}" y="${center + 3}" text-anchor="middle" font-family="Arial, sans-serif" font-size="9" font-weight="600" fill="#ffffff">${label}</text></svg>`;
612+
return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`;
613+
}
614+
615+
function endpointGeometry(endpoint: "start" | "end") {
616+
return (feature: FeatureLike) => {
617+
const geometry = feature.getGeometry();
618+
if (!(geometry instanceof LineString)) return undefined;
619+
620+
const coordinates = geometry.getCoordinates();
621+
const coordinate =
622+
endpoint === "start" ? coordinates[0] : coordinates.at(-1);
623+
return coordinate ? new Point(coordinate) : undefined;
624+
};
625+
}
626+
516627
function createBasemapLayer(basemapId: BasemapId): TileLayer<XYZ> {
517628
const basemap = getBasemap(basemapId);
518629
const source = new XYZ({

0 commit comments

Comments
 (0)