Skip to content

Commit 10295bd

Browse files
committed
fix coderabbitai requested changes
1 parent 088f894 commit 10295bd

4 files changed

Lines changed: 146 additions & 28 deletions

File tree

packages/plugins/src/plugins/maplibre-a5.ts

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -274,11 +274,30 @@ export function setA5GridSettings(patch: Partial<A5GridSettings>): void {
274274
if (panelContainer) renderPanel(panelContainer);
275275
}
276276

277+
/**
278+
* Unwrap antimeridian-crossing A5 rings so longitudes stay contiguous.
279+
* a5-js returns raw ±180 jumps; MapLibre needs the adjacent world copy.
280+
*/
281+
export function a5UnwrapBoundary(ring: [number, number][]): [number, number][] {
282+
if (ring.length === 0) return ring;
283+
const out: [number, number][] = [];
284+
for (const [lng, lat] of ring) {
285+
let lon = lng;
286+
if (out.length > 0) {
287+
const reference = out[0][0];
288+
if (lon - reference > 180) lon -= 360;
289+
if (lon - reference < -180) lon += 360;
290+
}
291+
out.push([lon, lat]);
292+
}
293+
return out;
294+
}
295+
277296
/** Convert an A5 cell (hex identifier) to a GeoJSON polygon with export attributes. */
278297
export function a5CellFeature(cell: string): Feature<Polygon> {
279298
const id = hexToU64(cell);
280299
const [lng, lat] = cellToLonLat(id);
281-
const boundary = cellToBoundary(id) as [number, number][];
300+
const boundary = a5UnwrapBoundary(cellToBoundary(id) as [number, number][]);
282301
return {
283302
type: "Feature",
284303
id: cell,
@@ -559,9 +578,9 @@ function gridCsv(grid: FeatureCollection<Polygon>): string {
559578

560579
function fitSelected(): void {
561580
if (!selectedCell || !appRef) return;
562-
// The ring is contiguous even across the antimeridian, so min/max longitudes
563-
// never span the world.
564-
const ring = cellToBoundary(hexToU64(selectedCell));
581+
// The ring is unwrapped to stay contiguous across the antimeridian, so
582+
// min/max longitudes never span the world.
583+
const ring = a5UnwrapBoundary(cellToBoundary(hexToU64(selectedCell)) as [number, number][]);
565584
const lons = ring.map(([lng]) => lng);
566585
const lats = ring.map(([, lat]) => lat);
567586
appRef.fitBounds?.([Math.min(...lons), Math.min(...lats), Math.max(...lons), Math.max(...lats)]);

packages/plugins/src/plugins/maplibre-dggal.ts

Lines changed: 50 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -194,18 +194,23 @@ let currentGrid: FeatureCollection<Polygon> = { type: "FeatureCollection", featu
194194
let currentError: string | null = null;
195195
let cachedTextFont: string[] | null = null;
196196
let pendingRefresh: number | null = null;
197+
/** Bumped on every activate/deactivate so an in-flight WASM load cannot attach after teardown. */
198+
let activationGeneration = 0;
197199

198200
let dggalPromise: Promise<DggalEngine> | null = null;
199201

200202
/**
201203
* Load the DGGAL WASM module once and reuse the handle. Imported dynamically
202204
* so the ~1 MB module stays out of the main bundle until the plugin is
203-
* activated.
205+
* activated. A failed load clears the cache so the next activate can retry.
204206
*/
205207
export function loadDggal(): Promise<DggalEngine> {
206-
dggalPromise ??= import("dggal").then(
207-
(module) => module.DGGAL.init() as unknown as Promise<DggalEngine>,
208-
);
208+
dggalPromise ??= import("dggal")
209+
.then((module) => module.DGGAL.init() as unknown as Promise<DggalEngine>)
210+
.catch((error) => {
211+
dggalPromise = null;
212+
throw error;
213+
});
209214
return dggalPromise;
210215
}
211216

@@ -459,6 +464,24 @@ export function dggalGridForBounds(
459464
let [west, south, east, north] = bounds;
460465
south = Math.max(-90, Math.min(90, south));
461466
north = Math.max(-90, Math.min(90, north));
467+
// Wrapped antimeridian bounds (west > east) must be split — a negative span
468+
// undercounts area and feeds listZones an inverted bbox.
469+
if (east < west) {
470+
const left = dggalGridForBounds(engine, [west, south, 180, north], resolution, limit);
471+
const right = dggalGridForBounds(engine, [-180, south, east, north], resolution, limit);
472+
const seen = new Set<string>();
473+
const features: Feature<Polygon>[] = [];
474+
for (const feature of [...left.features, ...right.features]) {
475+
const id = String(feature.properties?.dggal ?? feature.id);
476+
if (seen.has(id)) continue;
477+
seen.add(id);
478+
features.push(feature);
479+
if (features.length > limit) {
480+
throw new RangeError(`DGGAL zone limit exceeded: ${limit}`);
481+
}
482+
}
483+
return { type: "FeatureCollection", features };
484+
}
462485
if (east - west >= 360) {
463486
west = -180;
464487
east = 180;
@@ -613,7 +636,20 @@ function refresh(): void {
613636
applyStyle();
614637
(map.getSource(SOURCE_ID) as GeoJSONSource | undefined)?.setData(currentGrid);
615638
updateSelectedSource();
616-
if (panelContainer) renderPanel(panelContainer);
639+
// Pan/zoom only changes the cell count status — rebuild the whole panel and
640+
// the open color picker / focused inputs are destroyed mid-gesture.
641+
updatePanelStatus();
642+
}
643+
644+
/** Update the status line without recreating the rest of the panel controls. */
645+
function updatePanelStatus(): void {
646+
const status = panelContainer?.querySelector<HTMLElement>("[data-dggal-status]");
647+
if (!status) {
648+
if (panelContainer) renderPanel(panelContainer);
649+
return;
650+
}
651+
status.textContent = currentError ?? labels.cellCount(currentGrid.features.length);
652+
status.style.color = currentError ? "#dc2626" : "";
617653
}
618654

619655
/**
@@ -857,6 +893,7 @@ function renderPanel(container: HTMLElement): void {
857893
}
858894

859895
const status = document.createElement("div");
896+
status.dataset.dggalStatus = "";
860897
status.textContent = currentError ?? labels.cellCount(currentGrid.features.length);
861898
status.style.color = currentError ? "#dc2626" : "";
862899
section.appendChild(status);
@@ -989,7 +1026,12 @@ export const maplibreDggalPlugin: GeoLibrePlugin = {
9891026
activate: async (app) => {
9901027
const activeMap = app.getMap?.();
9911028
if (!activeMap) return false;
992-
dggal = await loadDggal();
1029+
const generation = (activationGeneration += 1);
1030+
// Await WASM before mutating map/panel state so a deactivate during the
1031+
// load cannot race a late attach (leaked listeners / panels / layers).
1032+
const engine = await loadDggal();
1033+
if (generation !== activationGeneration) return false;
1034+
dggal = engine;
9931035
map = activeMap;
9941036
appRef = app;
9951037
moveHandler = () => scheduleRefresh();
@@ -1029,6 +1071,7 @@ export const maplibreDggalPlugin: GeoLibrePlugin = {
10291071
app.openRightPanel?.(PANEL_ID);
10301072
},
10311073
deactivate: (app) => {
1074+
activationGeneration += 1;
10321075
cancelScheduledRefresh();
10331076
if (map && moveHandler) map.off("moveend", moveHandler);
10341077
if (map && clickHandler) map.off("click", clickHandler);
@@ -1047,6 +1090,7 @@ export const maplibreDggalPlugin: GeoLibrePlugin = {
10471090
currentGrid = { type: "FeatureCollection", features: [] };
10481091
currentError = null;
10491092
cachedTextFont = null;
1093+
dggal = null;
10501094
map = null;
10511095
appRef = null;
10521096
app.closeRightPanel?.(PANEL_ID);

packages/plugins/src/plugins/maplibre-dggrid.ts

Lines changed: 66 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -204,21 +204,29 @@ let currentGrid: FeatureCollection<Polygon> = { type: "FeatureCollection", featu
204204
let currentError: string | null = null;
205205
let cachedTextFont: string[] | null = null;
206206
let pendingRefresh: number | null = null;
207+
/** Bumped on every activate/deactivate so an in-flight WASM load cannot attach after teardown. */
208+
let activationGeneration = 0;
207209

208210
let dggsPromise: Promise<DggridEngine> | null = null;
209211

210212
/**
211213
* Load the webdggrid WASM module once and reuse the instance. Imported
212214
* dynamically so the ~270 kB module stays out of the main bundle until the
213215
* plugin is activated. `Webdggrid.load()` is typed as returning the class but
214-
* resolves to an instance, hence the cast.
216+
* resolves to an instance, hence the cast. A failed load clears the cache so
217+
* the next activate can retry.
215218
*/
216219
export function loadDggrid(): Promise<DggridEngine> {
217-
dggsPromise ??= import("webdggrid").then(async (module) => {
218-
const instance = (await module.Webdggrid.load()) as unknown as DggridEngine;
219-
instance.setDggs({ ...DGGRID_CONFIG }, DEFAULT_DGGRID_GRID_SETTINGS.resolution);
220-
return instance;
221-
});
220+
dggsPromise ??= import("webdggrid")
221+
.then(async (module) => {
222+
const instance = (await module.Webdggrid.load()) as unknown as DggridEngine;
223+
instance.setDggs({ ...DGGRID_CONFIG }, DEFAULT_DGGRID_GRID_SETTINGS.resolution);
224+
return instance;
225+
})
226+
.catch((error) => {
227+
dggsPromise = null;
228+
throw error;
229+
});
222230
return dggsPromise;
223231
}
224232

@@ -496,13 +504,32 @@ function ringIntersectsBounds(
496504
north: number,
497505
): boolean {
498506
if (!ring?.length) return false;
499-
const last = ring.length - 1;
500-
const closed = ring[0][0] === ring[last][0] && ring[0][1] === ring[last][1];
501-
const count = closed ? ring.length - 1 : ring.length;
507+
// Unwrap for continuity, then shift into the rect's longitude window so
508+
// vertex / containment / segment tests share one frame (raw ±180 mixes break
509+
// antimeridian cells).
510+
const framed: Position[] = [];
511+
for (const [rawLon, lat] of ring) {
512+
let lon = rawLon;
513+
if (framed.length > 0) {
514+
const reference = framed[0][0];
515+
while (lon - reference > 180) lon -= 360;
516+
while (lon - reference < -180) lon += 360;
517+
}
518+
framed.push([lon, lat]);
519+
}
520+
const mid = (west + east) / 2;
521+
const shift = Math.round((mid - framed[0][0]) / 360) * 360;
522+
const normalized =
523+
shift === 0 ? framed : framed.map(([lon, lat]) => [lon + shift, lat] as Position);
524+
525+
const last = normalized.length - 1;
526+
const closed =
527+
normalized[0][0] === normalized[last][0] && normalized[0][1] === normalized[last][1];
528+
const count = closed ? normalized.length - 1 : normalized.length;
502529

503530
for (let i = 0; i < count; i += 1) {
504-
const lon = normalizeLon(ring[i][0]);
505-
const lat = ring[i][1];
531+
const lon = normalized[i][0];
532+
const lat = normalized[i][1];
506533
if (lon >= west && lon <= east && lat >= south && lat <= north) return true;
507534
}
508535
for (const [lon, lat] of [
@@ -512,16 +539,16 @@ function ringIntersectsBounds(
512539
[east, north],
513540
[(west + east) / 2, (south + north) / 2],
514541
]) {
515-
if (pointInRing(lon, lat, ring)) return true;
542+
if (pointInRing(lon, lat, normalized)) return true;
516543
}
517544
for (let i = 0; i < count; i += 1) {
518545
const j = (i + 1) % count;
519546
if (
520547
segmentCrossesLonLatRect(
521-
normalizeLon(ring[i][0]),
522-
ring[i][1],
523-
normalizeLon(ring[j][0]),
524-
ring[j][1],
548+
normalized[i][0],
549+
normalized[i][1],
550+
normalized[j][0],
551+
normalized[j][1],
525552
west,
526553
south,
527554
east,
@@ -833,7 +860,20 @@ function refresh(): void {
833860
applyStyle();
834861
(map.getSource(SOURCE_ID) as GeoJSONSource | undefined)?.setData(currentGrid);
835862
updateSelectedSource();
836-
if (panelContainer) renderPanel(panelContainer);
863+
// Pan/zoom only changes the cell count status — rebuild the whole panel and
864+
// the open color picker / focused inputs are destroyed mid-gesture.
865+
updatePanelStatus();
866+
}
867+
868+
/** Update the status line without recreating the rest of the panel controls. */
869+
function updatePanelStatus(): void {
870+
const status = panelContainer?.querySelector<HTMLElement>("[data-dggrid-status]");
871+
if (!status) {
872+
if (panelContainer) renderPanel(panelContainer);
873+
return;
874+
}
875+
status.textContent = currentError ?? labels.cellCount(currentGrid.features.length);
876+
status.style.color = currentError ? "#dc2626" : "";
837877
}
838878

839879
/**
@@ -1085,6 +1125,7 @@ function renderPanel(container: HTMLElement): void {
10851125
}
10861126

10871127
const status = document.createElement("div");
1128+
status.dataset.dggridStatus = "";
10881129
status.textContent = currentError ?? labels.cellCount(currentGrid.features.length);
10891130
status.style.color = currentError ? "#dc2626" : "";
10901131
section.appendChild(status);
@@ -1210,7 +1251,12 @@ export const maplibreDggridPlugin: GeoLibrePlugin = {
12101251
activate: async (app) => {
12111252
const activeMap = app.getMap?.();
12121253
if (!activeMap) return false;
1213-
dggs = await loadDggrid();
1254+
const generation = (activationGeneration += 1);
1255+
// Await WASM before mutating map/panel state so a deactivate during the
1256+
// load cannot race a late attach (leaked listeners / panels / layers).
1257+
const engine = await loadDggrid();
1258+
if (generation !== activationGeneration) return false;
1259+
dggs = engine;
12141260
map = activeMap;
12151261
appRef = app;
12161262
moveHandler = () => scheduleRefresh();
@@ -1249,6 +1295,7 @@ export const maplibreDggridPlugin: GeoLibrePlugin = {
12491295
app.openRightPanel?.(PANEL_ID);
12501296
},
12511297
deactivate: (app) => {
1298+
activationGeneration += 1;
12521299
cancelScheduledRefresh();
12531300
if (map && moveHandler) map.off("moveend", moveHandler);
12541301
if (map && clickHandler) map.off("click", clickHandler);
@@ -1265,6 +1312,7 @@ export const maplibreDggridPlugin: GeoLibrePlugin = {
12651312
currentGrid = { type: "FeatureCollection", features: [] };
12661313
currentError = null;
12671314
cachedTextFont = null;
1315+
dggs = null;
12681316
map = null;
12691317
appRef = null;
12701318
app.closeRightPanel?.(PANEL_ID);

packages/plugins/src/plugins/maplibre-olc.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -368,13 +368,20 @@ export function olcGridForBounds(
368368
const startLat = Math.max(-90, Math.floor((south + 90) / latHeight) * latHeight - 90);
369369

370370
const features: Feature<Polygon>[] = [];
371+
// Floating-point walks of the grid can land on the same cell twice near
372+
// cell boundaries; key by (id, world copy) so a dateline-crossing view can
373+
// still draw the same code in two adjacent copies.
374+
const seen = new Set<string>();
371375
for (let lng = startLng; lng < east; lng += lngWidth) {
372376
for (let lat = startLat; lat < north && lat < 90; lat += latHeight) {
373377
const centerLng = lng + lngWidth / 2;
374378
const cell = OpenLocationCode.encode(lat + latHeight / 2, centerLng, codeLength);
375379
// 360° multiple between the drawn column and the normalized cell.
376380
const lngOffset =
377381
Math.round((centerLng - OpenLocationCode.decode(cell).longitudeCenter) / 360) * 360;
382+
const key = `${cell}@${lngOffset}`;
383+
if (seen.has(key)) continue;
384+
seen.add(key);
378385
features.push(olcCellFeature(cell, lngOffset));
379386
if (features.length > limit) {
380387
throw new RangeError(`OLC cell limit exceeded: ${limit}`);

0 commit comments

Comments
 (0)