From 9b370d8505a0efd3dc59d62f751ed41c3707dc1b Mon Sep 17 00:00:00 2001 From: Max Kohler Date: Sat, 25 Oct 2025 16:47:59 +0200 Subject: [PATCH 01/10] Extract type --- .../src/maplibre/ArrowSource/ArrowSource.svelte | 3 +-- .../src/maplibre/ArrowSource/quadraticToPoints.ts | 12 +++++------- components/src/maplibre/ArrowSource/types.ts | 3 +++ 3 files changed, 9 insertions(+), 9 deletions(-) create mode 100644 components/src/maplibre/ArrowSource/types.ts diff --git a/components/src/maplibre/ArrowSource/ArrowSource.svelte b/components/src/maplibre/ArrowSource/ArrowSource.svelte index 334fadf..602e817 100644 --- a/components/src/maplibre/ArrowSource/ArrowSource.svelte +++ b/components/src/maplibre/ArrowSource/ArrowSource.svelte @@ -5,11 +5,10 @@ import { getMapContext } from '../context.svelte.js'; import quadraticToPoints from './quadraticToPoints'; import { onDestroy } from 'svelte'; + import type { V2 } from './types'; const { map } = $derived(getMapContext()); - type V2 = [number, number]; - interface ArrowSourceProps { id: string; attribution: string; diff --git a/components/src/maplibre/ArrowSource/quadraticToPoints.ts b/components/src/maplibre/ArrowSource/quadraticToPoints.ts index d971af6..8552066 100644 --- a/components/src/maplibre/ArrowSource/quadraticToPoints.ts +++ b/components/src/maplibre/ArrowSource/quadraticToPoints.ts @@ -8,12 +8,10 @@ Source: https://en.wikipedia.org/wiki/B%C3%A9zier_curve#Quadratic_B%C3%A9zier_cu Below is a naive implementation but good enough for now. For a better approach see: https://bit-101.com/blog/posts/2024-09-29/evenly-placed-points-on-bezier-curves */ -const quadraticToPoints = ( - a: [number, number], - b: [number, number], - c: [number, number], - n = 10 -) => { + +import type { V2 } from './types'; + +const quadraticToPoints = (a: V2, b: V2, c: V2, n = 10) => { let points = []; for (let i = 0; i < n; i++) { const t = i / n; @@ -21,7 +19,7 @@ const quadraticToPoints = ( const y = (1 - t) * ((1 - t) * a[1] + t * c[1]) + t * ((1 - t) * c[1] + t * b[1]); points.push([x, y]); } - return [...points, b] as [number, number][]; + return [...points, b] as V2[]; }; export default quadraticToPoints; diff --git a/components/src/maplibre/ArrowSource/types.ts b/components/src/maplibre/ArrowSource/types.ts new file mode 100644 index 0000000..6fc3eb6 --- /dev/null +++ b/components/src/maplibre/ArrowSource/types.ts @@ -0,0 +1,3 @@ +type V2 = [number, number]; + +export type { V2 }; From 6f098c9d30713d44e9000d4477afe7c9d946b151 Mon Sep 17 00:00:00 2001 From: Max Kohler Date: Mon, 27 Oct 2025 10:10:02 +0100 Subject: [PATCH 02/10] chore(Release): Disallow concurrent runs --- .github/workflows/release.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c34476f..4d3908b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,6 +10,10 @@ on: permissions: contents: read # for checkout +concurrency: + group: components-release + cancel-in-progress: false + jobs: release: name: Release From 2d9377329ef14d595e8934ef272383f6eaa0f644 Mon Sep 17 00:00:00 2001 From: Max Kohler Date: Mon, 3 Nov 2025 21:57:41 +0100 Subject: [PATCH 03/10] Make a start on the algorithm --- .../ArrowSource/ArrowSource.stories.svelte | 15 +++- .../maplibre/ArrowSource/ArrowSource.svelte | 11 ++- .../ArrowSource/quadraticToPoints.test.ts | 0 .../maplibre/ArrowSource/quadraticToPoints.ts | 69 +++++++++++++++++-- 4 files changed, 84 insertions(+), 11 deletions(-) create mode 100644 components/src/maplibre/ArrowSource/quadraticToPoints.test.ts diff --git a/components/src/maplibre/ArrowSource/ArrowSource.stories.svelte b/components/src/maplibre/ArrowSource/ArrowSource.stories.svelte index 00b6283..e5c1428 100644 --- a/components/src/maplibre/ArrowSource/ArrowSource.stories.svelte +++ b/components/src/maplibre/ArrowSource/ArrowSource.stories.svelte @@ -22,9 +22,9 @@ showDebug={true} style={SWRDataLabLight()} initialLocation={{ - lng: -84.783, - lat: 15.623, - zoom: 3.39, + lng: -78.40441556736909, + lat: 13.411905478643874, + zoom: 5.124074967275398, pitch: 0 }} > @@ -74,6 +74,15 @@ 'fill-color': tokens.shades.red.base }} /> + diff --git a/components/src/maplibre/ArrowSource/ArrowSource.svelte b/components/src/maplibre/ArrowSource/ArrowSource.svelte index 602e817..583fb28 100644 --- a/components/src/maplibre/ArrowSource/ArrowSource.svelte +++ b/components/src/maplibre/ArrowSource/ArrowSource.svelte @@ -34,7 +34,7 @@ const ars: JsonArrow[] = arrows.map((a) => { return { width: a.width || 10, - points: quadraticToPoints(a.a, a.b, a.c, 10), + points: quadraticToPoints(a.a, a.b, a.c, 6), headScale: a.headScale }; }); @@ -49,6 +49,13 @@ properties: { width: a.width, kind: 'arrow-tail', id: i } }; }); + const debug = ars.map((a, i) => { + return { + type: 'Feature', + geometry: { type: 'MultiPoint', coordinates: a.points }, + properties: { kind: 'arrow-debug', id: i } + }; + }); const heads = ars.map((arrow, i) => { const a = Object.values(map.project(arrow.points[arrow.points.length - 1])) as V2; @@ -77,7 +84,7 @@ return { type: 'FeatureCollection', - features: [...tails, ...heads] + features: [...tails, ...heads, ...debug] } as GeoJSON.GeoJSON; }; diff --git a/components/src/maplibre/ArrowSource/quadraticToPoints.test.ts b/components/src/maplibre/ArrowSource/quadraticToPoints.test.ts new file mode 100644 index 0000000..e69de29 diff --git a/components/src/maplibre/ArrowSource/quadraticToPoints.ts b/components/src/maplibre/ArrowSource/quadraticToPoints.ts index 8552066..40eee78 100644 --- a/components/src/maplibre/ArrowSource/quadraticToPoints.ts +++ b/components/src/maplibre/ArrowSource/quadraticToPoints.ts @@ -7,19 +7,76 @@ Source: https://en.wikipedia.org/wiki/B%C3%A9zier_curve#Quadratic_B%C3%A9zier_cu Below is a naive implementation but good enough for now. For a better approach see: https://bit-101.com/blog/posts/2024-09-29/evenly-placed-points-on-bezier-curves - */ +*/ import type { V2 } from './types'; +const distance = (a: V2, b: V2) => { + return Math.sqrt((b[0] - a[0]) ** 2 + (b[1] - a[1]) ** 2); +}; + +const norm = (v: V2) => { + const d = distance([0, 0], v); + return [v[0] * d, v[1] * d]; +}; + +const findSegment = (segments: number[], v: number) => { + let low = 0; + let high = segments.length; + while (low < high) { + let mid = (low + high) >>> 1; // * .5 but faster + if (segments[mid] < v) low = mid + 1; + else high = mid; + } + return low; +}; + +const interpolatePolyline = (points: V2[], n: number) => { + let res: V2[] = []; + let segments: number[] = []; + + let totalLength = 0; + for (let i = 0; i < points.length - 1; i++) { + totalLength += distance(points[i], points[i + 1]); + segments.push(totalLength); + } + + console.log(segments); + console.log(totalLength); + + for (let i = 0; i < n; i++) { + const d: number = totalLength * (i / n); + const si: number = findSegment(segments, d); + console.log({ d, si }); + const p0 = points[si]; + const p1 = points[si + 1]; + + const sn = norm([p1[0] - p1[0], p0[1] - p1[1]]); + const segmentFraction = (d - segments[si]) / distance(p0, p1); + + res.push([ + points[si][0] + sn[0] * segmentFraction * distance(p0, p1), + points[si][1] + sn[1] * segmentFraction * distance(p0, p1) + ]); + } + + return res; +}; + const quadraticToPoints = (a: V2, b: V2, c: V2, n = 10) => { - let points = []; + let points: V2[] = []; + for (let i = 0; i < n; i++) { const t = i / n; - const x = (1 - t) * ((1 - t) * a[0] + t * c[0]) + t * ((1 - t) * c[0] + t * b[0]); - const y = (1 - t) * ((1 - t) * a[1] + t * c[1]) + t * ((1 - t) * c[1] + t * b[1]); - points.push([x, y]); + points.push([ + (1 - t) * ((1 - t) * a[0] + t * c[0]) + t * ((1 - t) * c[0] + t * b[0]), + (1 - t) * ((1 - t) * a[1] + t * c[1]) + t * ((1 - t) * c[1] + t * b[1]) + ]); } - return [...points, b] as V2[]; + + points = interpolatePolyline([...points, b], 10); + + return points; }; export default quadraticToPoints; From fa85ba9e46332baad1041315a536d210a6cedb45 Mon Sep 17 00:00:00 2001 From: Max Kohler Date: Tue, 4 Nov 2025 13:27:14 +0100 Subject: [PATCH 04/10] Blokc out working algorithm --- .../ArrowSource/ArrowSource.stories.svelte | 12 ++---------- .../maplibre/ArrowSource/ArrowSource.svelte | 11 ++--------- .../maplibre/ArrowSource/quadraticToPoints.ts | 19 ++++++++----------- 3 files changed, 12 insertions(+), 30 deletions(-) diff --git a/components/src/maplibre/ArrowSource/ArrowSource.stories.svelte b/components/src/maplibre/ArrowSource/ArrowSource.stories.svelte index e5c1428..6288526 100644 --- a/components/src/maplibre/ArrowSource/ArrowSource.stories.svelte +++ b/components/src/maplibre/ArrowSource/ArrowSource.stories.svelte @@ -60,11 +60,12 @@ 0, 'transparent', 0.4, - tokens.shades.red.base + tokens.shades.orange.base ], 'line-width': ['get', 'width'] }} /> + - diff --git a/components/src/maplibre/ArrowSource/ArrowSource.svelte b/components/src/maplibre/ArrowSource/ArrowSource.svelte index 583fb28..3136fe3 100644 --- a/components/src/maplibre/ArrowSource/ArrowSource.svelte +++ b/components/src/maplibre/ArrowSource/ArrowSource.svelte @@ -34,7 +34,7 @@ const ars: JsonArrow[] = arrows.map((a) => { return { width: a.width || 10, - points: quadraticToPoints(a.a, a.b, a.c, 6), + points: quadraticToPoints(a.a, a.b, a.c), headScale: a.headScale }; }); @@ -49,13 +49,6 @@ properties: { width: a.width, kind: 'arrow-tail', id: i } }; }); - const debug = ars.map((a, i) => { - return { - type: 'Feature', - geometry: { type: 'MultiPoint', coordinates: a.points }, - properties: { kind: 'arrow-debug', id: i } - }; - }); const heads = ars.map((arrow, i) => { const a = Object.values(map.project(arrow.points[arrow.points.length - 1])) as V2; @@ -84,7 +77,7 @@ return { type: 'FeatureCollection', - features: [...tails, ...heads, ...debug] + features: [...tails, ...heads] } as GeoJSON.GeoJSON; }; diff --git a/components/src/maplibre/ArrowSource/quadraticToPoints.ts b/components/src/maplibre/ArrowSource/quadraticToPoints.ts index 40eee78..1f2a731 100644 --- a/components/src/maplibre/ArrowSource/quadraticToPoints.ts +++ b/components/src/maplibre/ArrowSource/quadraticToPoints.ts @@ -24,7 +24,7 @@ const findSegment = (segments: number[], v: number) => { let low = 0; let high = segments.length; while (low < high) { - let mid = (low + high) >>> 1; // * .5 but faster + let mid = (low + high) >>> 1; // === Math.round((low + high) * .5) but faster if (segments[mid] < v) low = mid + 1; else high = mid; } @@ -33,16 +33,16 @@ const findSegment = (segments: number[], v: number) => { const interpolatePolyline = (points: V2[], n: number) => { let res: V2[] = []; - let segments: number[] = []; + let segments: number[] = [0]; let totalLength = 0; - for (let i = 0; i < points.length - 1; i++) { + for (let i = 0; i < points.length - 2; i++) { totalLength += distance(points[i], points[i + 1]); segments.push(totalLength); } console.log(segments); - console.log(totalLength); + console.log({ points, totalLength }); for (let i = 0; i < n; i++) { const d: number = totalLength * (i / n); @@ -51,19 +51,16 @@ const interpolatePolyline = (points: V2[], n: number) => { const p0 = points[si]; const p1 = points[si + 1]; - const sn = norm([p1[0] - p1[0], p0[1] - p1[1]]); + const sn = [p1[0] - p0[0], p1[1] - p0[1]]; const segmentFraction = (d - segments[si]) / distance(p0, p1); - res.push([ - points[si][0] + sn[0] * segmentFraction * distance(p0, p1), - points[si][1] + sn[1] * segmentFraction * distance(p0, p1) - ]); + res.push([p1[0] + sn[0] * segmentFraction, p1[1] + sn[1] * segmentFraction]); } return res; }; -const quadraticToPoints = (a: V2, b: V2, c: V2, n = 10) => { +const quadraticToPoints = (a: V2, b: V2, c: V2, n = 30) => { let points: V2[] = []; for (let i = 0; i < n; i++) { @@ -74,7 +71,7 @@ const quadraticToPoints = (a: V2, b: V2, c: V2, n = 10) => { ]); } - points = interpolatePolyline([...points, b], 10); + points = interpolatePolyline([...points, b], n); return points; }; From f30450a5572c7b1954298240b1b92c48a0f5b475 Mon Sep 17 00:00:00 2001 From: Max Kohler Date: Tue, 4 Nov 2025 13:44:42 +0100 Subject: [PATCH 05/10] Cleanup --- .../ArrowSource/quadraticToPoints.test.ts | 0 .../maplibre/ArrowSource/quadraticToPoints.ts | 57 ++++++++++--------- 2 files changed, 29 insertions(+), 28 deletions(-) delete mode 100644 components/src/maplibre/ArrowSource/quadraticToPoints.test.ts diff --git a/components/src/maplibre/ArrowSource/quadraticToPoints.test.ts b/components/src/maplibre/ArrowSource/quadraticToPoints.test.ts deleted file mode 100644 index e69de29..0000000 diff --git a/components/src/maplibre/ArrowSource/quadraticToPoints.ts b/components/src/maplibre/ArrowSource/quadraticToPoints.ts index 1f2a731..f6fe771 100644 --- a/components/src/maplibre/ArrowSource/quadraticToPoints.ts +++ b/components/src/maplibre/ArrowSource/quadraticToPoints.ts @@ -1,53 +1,43 @@ -/** -Given a quadratic bezier defined by points a, b and c, -returns a series of n points on the curve - -The quadratic bezier is: B(t) = (1-t)[(1-t)P0 + tP1] + t[(1-t)P1+tP2] -Source: https://en.wikipedia.org/wiki/B%C3%A9zier_curve#Quadratic_B%C3%A9zier_curves - -Below is a naive implementation but good enough for now. For a better approach see: -https://bit-101.com/blog/posts/2024-09-29/evenly-placed-points-on-bezier-curves -*/ - import type { V2 } from './types'; +/** + * Returns the euclidian distance between two points + */ const distance = (a: V2, b: V2) => { return Math.sqrt((b[0] - a[0]) ** 2 + (b[1] - a[1]) ** 2); }; -const norm = (v: V2) => { - const d = distance([0, 0], v); - return [v[0] * d, v[1] * d]; -}; - -const findSegment = (segments: number[], v: number) => { - let low = 0; - let high = segments.length; +/** + * Given a sorted array arr and a value, returns the index i of arr + * such that arr[i] < value && arr[i + 1] > value + */ +const getSortedIndex = (arr: number[], v: number) => { + let low = 0, + high = arr.length; while (low < high) { let mid = (low + high) >>> 1; // === Math.round((low + high) * .5) but faster - if (segments[mid] < v) low = mid + 1; + if (arr[mid] < v) low = mid + 1; else high = mid; } return low; }; +/** + * Given a polyline, returns n evenly-spaced points on the polyline + */ const interpolatePolyline = (points: V2[], n: number) => { - let res: V2[] = []; - let segments: number[] = [0]; + let res: V2[] = [], + totalLength: number = 0, + segments: number[] = [0]; - let totalLength = 0; for (let i = 0; i < points.length - 2; i++) { totalLength += distance(points[i], points[i + 1]); segments.push(totalLength); } - console.log(segments); - console.log({ points, totalLength }); - for (let i = 0; i < n; i++) { const d: number = totalLength * (i / n); - const si: number = findSegment(segments, d); - console.log({ d, si }); + const si: number = getSortedIndex(segments, d); const p0 = points[si]; const p1 = points[si + 1]; @@ -60,9 +50,19 @@ const interpolatePolyline = (points: V2[], n: number) => { return res; }; +/** +Given a quadratic bezier defined by points a, b and c, +returns a series of n points on the curve + +See: +- https://en.wikipedia.org/wiki/B%C3%A9zier_curve#Quadratic_B%C3%A9zier_curves +- https://bit-101.com/blog/posts/2024-09-29/evenly-placed-points-on-bezier-curves +*/ + const quadraticToPoints = (a: V2, b: V2, c: V2, n = 30) => { let points: V2[] = []; + // 1. Interpolate the quadratic bezier function to obtain an uneven polyline for (let i = 0; i < n; i++) { const t = i / n; points.push([ @@ -71,6 +71,7 @@ const quadraticToPoints = (a: V2, b: V2, c: V2, n = 30) => { ]); } + // 2. Interpolate the polyline from (1) to get evenly-spaced points points = interpolatePolyline([...points, b], n); return points; From e7c206d1a01333743db7ccc0e0951cbea2df2d72 Mon Sep 17 00:00:00 2001 From: Max Kohler Date: Tue, 4 Nov 2025 13:51:44 +0100 Subject: [PATCH 06/10] Clean up story --- .../ArrowSource/ArrowSource.stories.svelte | 14 +++++++------- .../src/maplibre/ArrowSource/ArrowSource.svelte | 4 ++-- .../src/maplibre/ArrowSource/quadraticToPoints.ts | 4 ++-- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/components/src/maplibre/ArrowSource/ArrowSource.stories.svelte b/components/src/maplibre/ArrowSource/ArrowSource.stories.svelte index 6288526..cdc4f48 100644 --- a/components/src/maplibre/ArrowSource/ArrowSource.stories.svelte +++ b/components/src/maplibre/ArrowSource/ArrowSource.stories.svelte @@ -6,8 +6,8 @@ import AttributionControl from '../AttributionControl/AttributionControl.svelte'; import { SWRDataLabLight } from '../MapStyle'; - import { tokens } from '../../DesignTokens'; import DesignTokens from '../../DesignTokens/DesignTokens.svelte'; + import { tokens } from '../../DesignTokens'; const { Story } = defineMeta({ title: 'Maplibre/Source/ArrowSource', @@ -22,9 +22,9 @@ showDebug={true} style={SWRDataLabLight()} initialLocation={{ - lng: -78.40441556736909, - lat: 13.411905478643874, - zoom: 5.124074967275398, + lng: -78.404, + lat: 13.411, + zoom: 5.124, pitch: 0 }} > @@ -39,7 +39,7 @@ c: [-81, 14.6] }, { - width: 15, + width: 25, a: [-71.1, 11.3], b: [-75.783, 15.6], c: [-75, 12.6] @@ -60,7 +60,7 @@ 0, 'transparent', 0.4, - tokens.shades.orange.base + tokens.shades.violet.base ], 'line-width': ['get', 'width'] }} @@ -72,7 +72,7 @@ filter={['==', 'kind', 'arrow-head']} type="fill" paint={{ - 'fill-color': tokens.shades.red.base + 'fill-color': tokens.shades.violet.base }} /> diff --git a/components/src/maplibre/ArrowSource/ArrowSource.svelte b/components/src/maplibre/ArrowSource/ArrowSource.svelte index 3136fe3..1b0584a 100644 --- a/components/src/maplibre/ArrowSource/ArrowSource.svelte +++ b/components/src/maplibre/ArrowSource/ArrowSource.svelte @@ -1,10 +1,10 @@