11"use client" ;
22
33import { useCallback , useEffect , useMemo , useRef } from "react" ;
4- import Feature from "ol/Feature" ;
4+ import Feature , { type FeatureLike } from "ol/Feature" ;
55import Map from "ol/Map" ;
66import View from "ol/View" ;
77import { defaults as defaultControls } from "ol/control/defaults" ;
@@ -23,8 +23,9 @@ import VectorSource from "ol/source/Vector";
2323import XYZ from "ol/source/XYZ" ;
2424import Fill from "ol/style/Fill" ;
2525import CircleStyle from "ol/style/Circle" ;
26+ import Icon from "ol/style/Icon" ;
2627import Stroke from "ol/style/Stroke" ;
27- import Style from "ol/style/Style" ;
28+ import Style , { type StyleFunction } from "ol/style/Style" ;
2829import TileGrid from "ol/tilegrid/TileGrid" ;
2930import { type BasemapId , getBasemap } from "~/lib/basemaps" ;
3031import 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+
6469const 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 ;
8099const basemapProjectionCode = "EPSG:3857" ;
81100
82101export 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+
516627function createBasemapLayer ( basemapId : BasemapId ) : TileLayer < XYZ > {
517628 const basemap = getBasemap ( basemapId ) ;
518629 const source = new XYZ ( {
0 commit comments