Skip to content

Commit 483c663

Browse files
Fix laggy globe dragging with atmospheric effects (#1653)
* perf: reduce redundant map rendering work * fix: preserve overlays and enforce effect frame pacing * fix: keep comet timing independent of refresh rate
1 parent 815c978 commit 483c663

6 files changed

Lines changed: 190 additions & 40 deletions

File tree

packages/map/src/MapCanvas.tsx

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1114,7 +1114,6 @@ export const MapCanvas = memo(function MapCanvas({
11141114
map.on("projectiontransition", updateProjection);
11151115
map.on("load", () => {
11161116
const state = useAppStore.getState();
1117-
mc.waitAndSyncLayers(applyGroupEffects(state.layers, state.layerGroups));
11181117
mc.setBasemapVisible(state.basemapVisible);
11191118
mc.setBasemapOpacity(state.basemapOpacity);
11201119
mc.highlightFeature(
@@ -1186,7 +1185,6 @@ export const MapCanvas = memo(function MapCanvas({
11861185
prevBasemap.current = basemapStyleUrl;
11871186
map.once("style.load", () => {
11881187
const state = useAppStore.getState();
1189-
controller.current?.waitAndSyncLayers(applyGroupEffects(state.layers, state.layerGroups));
11901188
controller.current?.setBasemapVisible(state.basemapVisible);
11911189
controller.current?.setBasemapOpacity(state.basemapOpacity);
11921190
controller.current?.highlightFeature(

packages/map/src/layer-sync.ts

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
validateMapExpression,
1515
} from "@geolibre/core";
1616
import { addProtocol, config } from "maplibre-gl";
17+
import type { GeoJSON } from "geojson";
1718
import type maplibregl from "maplibre-gl";
1819
import type { PropertyValueSpecification } from "maplibre-gl";
1920
import { FileSource, PMTiles, Protocol } from "pmtiles";
@@ -328,6 +329,18 @@ function applyExternalNativeFeatureFilters(
328329
// range we keep applying the style range on every sync, including a later reset
329330
// back to the full [0, 24] window.
330331
const managedZoomRangeLayerIds = new Set<string>();
332+
const geoJsonSourceData = new WeakMap<maplibregl.GeoJSONSource, GeoJSON>();
333+
334+
function rememberGeoJsonData(map: maplibregl.Map, sourceId: string, data: GeoJSON): void {
335+
const source = map.getSource(sourceId);
336+
if (source?.type === "geojson") geoJsonSourceData.set(source as maplibregl.GeoJSONSource, data);
337+
}
338+
339+
function setGeoJsonData(source: maplibregl.GeoJSONSource, data: GeoJSON): void {
340+
if (geoJsonSourceData.get(source) === data) return;
341+
source.setData(data);
342+
geoJsonSourceData.set(source, data);
343+
}
331344

332345
function clampLayerZoom(value: number, fallback: number): number {
333346
if (!Number.isFinite(value)) return fallback;
@@ -598,8 +611,9 @@ function ensureExternalGeoJsonNativeLayer(
598611
type: "geojson",
599612
data: layer.geojson,
600613
});
614+
rememberGeoJsonData(map, nativeSourceId, layer.geojson);
601615
} else {
602-
(map.getSource(nativeSourceId) as maplibregl.GeoJSONSource).setData(layer.geojson);
616+
setGeoJsonData(map.getSource(nativeSourceId) as maplibregl.GeoJSONSource, layer.geojson);
603617
}
604618

605619
if (nativeLayerIds.every((id) => map.getLayer(id))) return;
@@ -1438,6 +1452,10 @@ function setNativeLayerVisibility(
14381452
visibility: "visible" | "none",
14391453
): void {
14401454
try {
1455+
const canRead = typeof map.getLayoutProperty === "function";
1456+
const current = canRead ? map.getLayoutProperty(nativeLayerId, "visibility") : undefined;
1457+
if (current === visibility || (canRead && current === undefined && visibility === "visible"))
1458+
return;
14411459
map.setLayoutProperty(nativeLayerId, "visibility", visibility);
14421460
} catch {
14431461
// Custom layers from external controls may not accept layout updates.
@@ -1666,7 +1684,9 @@ function syncVectorControlPointSymbology(
16661684
// of the other rule-based paint overrides apply to control-owned layers.
16671685
const radius = proportionalRadiusExpression(layer.style);
16681686
if (radius) {
1669-
map.setPaintProperty(circleNativeId, "circle-radius", radius);
1687+
if (!styleValuesEqual(map.getPaintProperty?.(circleNativeId, "circle-radius"), radius)) {
1688+
map.setPaintProperty(circleNativeId, "circle-radius", radius);
1689+
}
16701690
overriddenRadiusIdsFor(map).add(circleNativeId);
16711691
} else {
16721692
restoreOverriddenCircleRadius(map, circleNativeId, layer);
@@ -1721,7 +1741,9 @@ function setExternalNativeLayerPaint(
17211741

17221742
for (const [property, value] of Object.entries(paint)) {
17231743
try {
1724-
map.setPaintProperty(nativeLayerId, property, value);
1744+
if (!styleValuesEqual(map.getPaintProperty?.(nativeLayerId, property), value)) {
1745+
map.setPaintProperty(nativeLayerId, property, value);
1746+
}
17251747
} catch {
17261748
// External controls can create heterogeneous style layers. Ignore paint
17271749
// properties that do not apply to a specific native layer type.
@@ -1807,8 +1829,9 @@ function syncGeoJsonLayer(map: maplibregl.Map, layer: GeoLibreLayer, beforeId?:
18071829
...(attribution ? { attribution } : {}),
18081830
},
18091831
);
1832+
rememberGeoJsonData(map, src, layer.geojson!);
18101833
} else {
1811-
(map.getSource(src) as maplibregl.GeoJSONSource).setData(layer.geojson!);
1834+
setGeoJsonData(map.getSource(src) as maplibregl.GeoJSONSource, layer.geojson!);
18121835
}
18131836

18141837
applyVectorDataRenderLayers(map, layer, src, profile, renderer, beforeId);
@@ -3403,12 +3426,16 @@ function ensureLayer(
34033426
if (map.getLayer(id)) {
34043427
if (spec.paint) {
34053428
for (const [key, value] of Object.entries(spec.paint)) {
3406-
map.setPaintProperty(id, key, value);
3429+
if (!styleValuesEqual(map.getPaintProperty?.(id, key), value)) {
3430+
map.setPaintProperty(id, key, value);
3431+
}
34073432
}
34083433
}
34093434
if (spec.layout) {
34103435
for (const [key, value] of Object.entries(spec.layout)) {
3411-
map.setLayoutProperty(id, key, value);
3436+
if (!styleValuesEqual(map.getLayoutProperty?.(id, key), value)) {
3437+
map.setLayoutProperty(id, key, value);
3438+
}
34123439
}
34133440
}
34143441
if ("filter" in spec) {
@@ -3462,6 +3489,10 @@ function ensureLayer(
34623489
map.addLayer(addSpec, validBeforeId);
34633490
}
34643491

3492+
export function styleValuesEqual(current: unknown, next: unknown): boolean {
3493+
return Object.is(current, next) || JSON.stringify(current) === JSON.stringify(next);
3494+
}
3495+
34653496
function setLayerZoomRange(
34663497
map: maplibregl.Map,
34673498
id: string,

packages/map/src/map-controller.ts

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import {
3737
import {
3838
mbtilesStyleLayerIds,
3939
removeLayerFromMap,
40+
styleValuesEqual,
4041
syncLayer,
4142
vectorTileStyleLayerIds,
4243
} from "./layer-sync";
@@ -1075,13 +1076,11 @@ export class MapController {
10751076
const map = this.map;
10761077

10771078
const nextIds = layers.map((l) => l.id);
1079+
const nextIdSet = new Set(nextIds);
1080+
const previousLayers = new Map(this.syncedLayers.map((layer) => [layer.id, layer]));
10781081
for (const id of this.layerIds) {
1079-
if (!nextIds.includes(id)) {
1080-
removeLayerFromMap(
1081-
map,
1082-
id,
1083-
this.syncedLayers.find((layer) => layer.id === id),
1084-
);
1082+
if (!nextIdSet.has(id)) {
1083+
removeLayerFromMap(map, id, previousLayers.get(id));
10851084
}
10861085
}
10871086

@@ -1109,17 +1108,28 @@ export class MapController {
11091108
}
11101109

11111110
private styleLoadHandler: (() => void) | null = null;
1111+
private styleReloadHandler: (() => void) | null = null;
11121112

11131113
waitAndSyncLayers(layers: GeoLibreLayer[]): void {
11141114
if (!this.map) return;
11151115

1116+
if (!this.styleReloadHandler) {
1117+
this.styleReloadHandler = () => {
1118+
if (!this.styleLoadHandler) this.syncLayers(this.syncedLayers);
1119+
};
1120+
this.map.on("style.load", this.styleReloadHandler);
1121+
}
1122+
11161123
if (this.styleLoadHandler) {
11171124
this.map.off("style.load", this.styleLoadHandler);
11181125
this.map.off("load", this.styleLoadHandler);
11191126
}
11201127

11211128
const run = () => {
11221129
if (this.styleLoadHandler !== run) return;
1130+
this.map?.off("load", run);
1131+
this.map?.off("style.load", run);
1132+
this.styleLoadHandler = null;
11231133
this.syncLayers(layers);
11241134
};
11251135
this.styleLoadHandler = run;
@@ -1128,17 +1138,21 @@ export class MapController {
11281138
run();
11291139
} else {
11301140
this.map.once("load", run);
1141+
this.map.once("style.load", run);
11311142
}
1132-
this.map.on("style.load", run);
11331143
}
11341144

11351145
private applyBasemapVisibility(): void {
11361146
if (!this.isStyleReady() || !this.map) return;
11371147
const map = this.map;
1148+
const visibility = this.basemapVisible ? "visible" : "none";
11381149

11391150
for (const layer of this.getBasemapStyleLayers()) {
11401151
try {
1141-
map.setLayoutProperty(layer.id, "visibility", this.basemapVisible ? "visible" : "none");
1152+
const current = map.getLayoutProperty(layer.id, "visibility");
1153+
if (current !== visibility && !(current === undefined && visibility === "visible")) {
1154+
map.setLayoutProperty(layer.id, "visibility", visibility);
1155+
}
11421156
} catch {
11431157
// Some third-party custom style layers may not expose layout properties.
11441158
}
@@ -1194,6 +1208,8 @@ export class MapController {
11941208
? original * this.basemapOpacity
11951209
: this.basemapOpacity;
11961210
try {
1211+
const current = this.map.getPaintProperty(layerId, property);
1212+
if (styleValuesEqual(current, opacity)) return;
11971213
this.map.setPaintProperty(layerId, property, opacity);
11981214
} catch {
11991215
// Some third-party custom style layers may not expose paint properties.

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

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,12 @@ const STARFIELD_LNG_PERIOD_DEGREES = 360;
8888
const STARFIELD_LAT_PERIOD_DEGREES = 180;
8989

9090
const HALO_SAMPLE_COUNT = 16;
91+
// Keep decorative canvas work from competing with MapLibre on high-refresh displays.
92+
const EFFECTS_FRAME_MS = 1000 / 60;
93+
94+
export function nextEffectsFrameTime(timestamp: number, lastFrameTime: number): number | null {
95+
return timestamp - lastFrameTime + 0.1 < EFFECTS_FRAME_MS ? null : timestamp;
96+
}
9197

9298
// Halo radial gradient as a *shape* independent of the chosen color: each stop
9399
// is [offset, alpha, shade] where offset is the fraction of the gradient span
@@ -458,6 +464,7 @@ class EffectsEngine {
458464
private settings: EffectsSettings;
459465

460466
private rafId: number | null = null;
467+
private lastFrameTime = -Infinity;
461468
private destroyed = false;
462469

463470
constructor(map: MapLibreMap, settings: EffectsSettings) {
@@ -700,18 +707,19 @@ class EffectsEngine {
700707
ctx.drawImage(field, wrappedX - this.width, wrappedY - this.height, this.width, this.height);
701708
}
702709

703-
private updateAndDrawComets(): void {
704-
// One comet at a time, spawned with ~0.5% probability per frame.
705-
if (this.comets.length === 0 && Math.random() < 0.005) {
710+
private updateAndDrawComets(frameScale: number): void {
711+
// Preserve the original ~0.5% chance per 60 Hz frame when frames are skipped.
712+
const spawnChance = 1 - Math.pow(1 - 0.005, frameScale);
713+
if (this.comets.length === 0 && Math.random() < spawnChance) {
706714
this.comets.push(this.spawnComet());
707715
}
708716

709717
const ctx = this.cometCtx;
710718
const survivors: Comet[] = [];
711719
for (const comet of this.comets) {
712-
comet.life += 1;
713-
comet.x += Math.cos(comet.angle) * comet.speed;
714-
comet.y += Math.sin(comet.angle) * comet.speed;
720+
comet.life += frameScale;
721+
comet.x += Math.cos(comet.angle) * comet.speed * frameScale;
722+
comet.y += Math.sin(comet.angle) * comet.speed * frameScale;
715723
comet.alpha = 1 - comet.life / comet.maxLife;
716724

717725
const offscreen =
@@ -803,10 +811,20 @@ class EffectsEngine {
803811
ctx.restore();
804812
}
805813

806-
private tick(): void {
814+
private tick(timestamp: number): void {
807815
this.rafId = null;
808816
if (this.destroyed) return;
809817

818+
const nextFrameTime = nextEffectsFrameTime(timestamp, this.lastFrameTime);
819+
if (nextFrameTime === null) {
820+
this.start();
821+
return;
822+
}
823+
const elapsed = Number.isFinite(this.lastFrameTime)
824+
? Math.min(nextFrameTime - this.lastFrameTime, EFFECTS_FRAME_MS * 2)
825+
: EFFECTS_FRAME_MS;
826+
this.lastFrameTime = nextFrameTime;
827+
810828
this.clear();
811829

812830
if (!isGlobeProjection(this.map)) {
@@ -821,7 +839,7 @@ class EffectsEngine {
821839
this.drawStarfield();
822840
this.starsDirty = false;
823841
}
824-
this.updateAndDrawComets();
842+
this.updateAndDrawComets(elapsed / EFFECTS_FRAME_MS);
825843
// The atmospheric halo is Earth-only — other celestial bodies (Moon, Mars,
826844
// Pluto, …) are airless or don't share Earth's blue glow, so we keep the
827845
// space backdrop, starfield, and comets but skip the halo for them.

tests/effects-settings.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
HALO_EXTENT_MIN,
77
HALO_OPACITY_MAX,
88
HALO_OPACITY_MIN,
9+
nextEffectsFrameTime,
910
normalizeEffectsSettings,
1011
} from "../packages/plugins/src/plugins/maplibre-effects";
1112

@@ -64,3 +65,23 @@ describe("normalizeEffectsSettings", () => {
6465
assert.equal(result.haloOpacity, 0.5);
6566
});
6667
});
68+
69+
describe("nextEffectsFrameTime", () => {
70+
it("keeps decorative frames one 60 FPS interval apart on high-refresh displays", () => {
71+
let lastFrameTime = -Infinity;
72+
const renderedAt: number[] = [];
73+
74+
for (let index = 0; index < 180; index += 1) {
75+
const timestamp = index * (1000 / 90);
76+
const nextFrameTime = nextEffectsFrameTime(timestamp, lastFrameTime);
77+
if (nextFrameTime === null) continue;
78+
renderedAt.push(nextFrameTime);
79+
lastFrameTime = nextFrameTime;
80+
}
81+
82+
assert.equal(renderedAt.length, 90);
83+
for (let index = 1; index < renderedAt.length; index += 1) {
84+
assert.ok(renderedAt[index] - renderedAt[index - 1] + 0.1 >= 1000 / 60);
85+
}
86+
});
87+
});

0 commit comments

Comments
 (0)