perf(map): reduce tile snapshot work during zoom

This commit is contained in:
2026-07-30 12:50:33 +08:00
parent 82e75c03d0
commit 1b2a7f4fb8
5 changed files with 592 additions and 161 deletions
+149 -127
View File
@@ -18,7 +18,6 @@ import MapTools from "./MapTools";
// 导入 DeckLayer
import { DeckLayer } from "@utils/layers";
import { toLonLat } from "ol/proj";
import { along, bearing, lineString, length } from "@turf/turf";
import { Deck } from "@deck.gl/core";
import { TextLayer } from "@deck.gl/layers";
import { TripsLayer } from "@deck.gl/geo-layers";
@@ -39,11 +38,15 @@ import { getRoundedCurrentTimelineMinutes } from "./Controls/timelineTime";
import { useTimelineTimeConfig } from "./Controls/useTimelineTimeConfig";
import {
TileFeatureIndex,
clipLineStringPartsToExtent,
coordinatesToLonLat,
buildPipeFeatureFragments,
lineStringFromFlatCoordinates,
type PipeFeatureFragment,
type TileFeatureInstance,
} from "./tileFeatureIndex";
import {
createTileSnapshotScheduler,
type TileSnapshotScheduler,
} from "./tileSnapshotScheduler";
interface MapComponentProps {
children?: React.ReactNode;
@@ -126,36 +129,6 @@ interface DataContextType {
const MapContext = createContext<OlMap | undefined>(undefined);
const DataContext = createContext<DataContextType | undefined>(undefined);
// 添加防抖函数
type DebouncedFunction<F extends (...args: any[]) => any> = ((
...args: Parameters<F>
) => void) & {
cancel: () => void;
};
function debounce<F extends (...args: any[]) => any>(
func: F,
waitFor: number
): DebouncedFunction<F> {
let timeout: ReturnType<typeof setTimeout> | null = null;
const debounced = (...args: Parameters<F>): void => {
if (timeout !== null) {
clearTimeout(timeout);
}
timeout = setTimeout(() => func(...args), waitFor);
};
debounced.cancel = () => {
if (timeout !== null) {
clearTimeout(timeout);
timeout = null;
}
};
return debounced;
}
const indexCalculationRecords = (records: any[]) =>
new Map(records.map((record) => [String(record.ID), record]));
@@ -234,6 +207,11 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
const compareCanvasRef = useRef<HTMLCanvasElement | null>(null);
const deckLayerRef = useRef<DeckLayer | null>(null);
const compareDeckLayerRef = useRef<DeckLayer | null>(null);
const tileSnapshotSchedulerRef = useRef<TileSnapshotScheduler | null>(null);
const publishedSnapshotKeyRef = useRef("");
const pipeFragmentCacheRef = useRef(
new WeakMap<TileFeatureInstance, PipeFeatureFragment[]>(),
);
const isDisposingRef = useRef(false);
const isCompareDisposingRef = useRef(false);
@@ -279,6 +257,30 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
useState(false); // 控制等高线图层显示
const [showWaterflowLayer, setShowWaterflowLayer] = useState(false); // 控制等高线图层显示
const [currentZoom, setCurrentZoom] = useState(11); // 当前缩放级别
const overlayRequirementsRef = useRef({
junctionData: false,
pipeLabels: false,
pipeFragments: false,
});
overlayRequirementsRef.current = {
junctionData:
currentZoom >= 11 &&
currentZoom <= 24 &&
(showContourLayer ||
(currentZoom >= 15 &&
(showJunctionTextLayer || showJunctionId))),
pipeLabels:
currentZoom >= 15 &&
currentZoom <= 24 &&
(showPipeTextLayer || showPipeId),
pipeFragments:
currentZoom >= 12 &&
currentZoom <= 24 &&
isWaterflowLayerAvailable &&
showWaterflowLayer &&
pipeText === "flow" &&
currentPipeCalData.length > 0,
};
// 实时合并计算结果到基础地理数据中
const mergedJunctionData = useMemo(
@@ -346,54 +348,12 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
[compareDeckLayer, deckLayer, isCompareMode],
);
const buildPipeFragments = useCallback((instance: TileFeatureInstance) => {
const tileCoordinates = lineStringFromFlatCoordinates(
instance.flatCoordinates,
instance.stride,
);
const clippedParts = clipLineStringPartsToExtent(
tileCoordinates,
instance.tileExtent,
);
return clippedParts.flatMap((clippedCoordinates, partIndex) => {
const path = coordinatesToLonLat(clippedCoordinates);
const lineStringFeature = lineString(path);
const fragmentLength = length(lineStringFeature);
if (fragmentLength <= 0) return [];
const timestamps = [0];
let cumulativeLength = 0;
for (let i = 1; i < path.length; i += 1) {
cumulativeLength += length(lineString([path[i - 1], path[i]]));
timestamps.push((cumulativeLength / fragmentLength) * 10);
}
const midPoint = along(lineStringFeature, fragmentLength / 2).geometry
.coordinates;
const prevPoint = along(lineStringFeature, fragmentLength * 0.49).geometry
.coordinates;
const nextPoint = along(lineStringFeature, fragmentLength * 0.51).geometry
.coordinates;
let lineAngle = bearing(prevPoint, nextPoint);
lineAngle = -lineAngle + 90;
if (lineAngle < -90 || lineAngle > 90) {
lineAngle += 180;
}
return [
{
instanceKey: `${instance.instanceKey}/${partIndex}`,
id: instance.featureId,
diameter: instance.properties.diameter || 0,
length: instance.properties.length || fragmentLength * 1000,
path,
position: midPoint,
angle: lineAngle,
timestamps,
fragmentLength,
},
];
});
const getPipeFragments = useCallback((instance: TileFeatureInstance) => {
const cached = pipeFragmentCacheRef.current.get(instance);
if (cached) return cached;
const fragments = buildPipeFeatureFragments(instance);
pipeFragmentCacheRef.current.set(instance, fragments);
return fragments;
}, []);
const publishActiveTileSnapshot = useCallback(
@@ -404,12 +364,30 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
zoom,
);
const pipeSnapshot = pipeIndexRef.current?.getSnapshot(targetMap, zoom);
if (!junctionSnapshot || !pipeSnapshot) return;
const nextJunctionData = Array.from(
junctionSnapshot?.instancesById.values() ?? [],
const requirements = overlayRequirementsRef.current;
const requirementSignature = [
requirements.junctionData ? 1 : 0,
requirements.pipeLabels ? 1 : 0,
requirements.pipeFragments ? 1 : 0,
].join("");
const snapshotKey = `${junctionSnapshot.signature}::${pipeSnapshot.signature}::${requirementSignature}`;
if (publishedSnapshotKeyRef.current === snapshotKey) return;
const junctionRepresentatives = Array.from(
junctionSnapshot.instancesById.values(),
)
.map((instances) => instances[0])
.filter(Boolean)
.filter(Boolean);
const pipeRepresentatives = Array.from(
pipeSnapshot.instancesById.values(),
)
.map((instances) => instances[0])
.filter(Boolean);
const nextJunctionData = requirements.junctionData
? junctionRepresentatives
.map((instance) => {
const [x, y] = lineStringFromFlatCoordinates(
instance.flatCoordinates,
@@ -423,15 +401,20 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
demand: instance.properties.demand || 0,
};
})
.sort((a, b) => String(a.id).localeCompare(String(b.id)));
.sort((a, b) => String(a.id).localeCompare(String(b.id)))
: [];
const nextPipeFragments = (pipeSnapshot?.instances ?? [])
const preparedPipeFragments =
requirements.pipeLabels || requirements.pipeFragments
? pipeSnapshot.instances
.filter((instance) => instance.geometryType.includes("Line"))
.flatMap(buildPipeFragments)
.sort((a, b) => a.instanceKey.localeCompare(b.instanceKey));
.flatMap(getPipeFragments)
.sort((a, b) => a.instanceKey.localeCompare(b.instanceKey))
: [];
const labelById = new Map<string, any>();
nextPipeFragments.forEach((fragment) => {
if (requirements.pipeLabels) {
preparedPipeFragments.forEach((fragment) => {
const previous = labelById.get(fragment.id);
if (
!previous ||
@@ -442,22 +425,52 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
labelById.set(fragment.id, fragment);
}
});
const nextPipeLabels = Array.from(labelById.values()).sort((a, b) =>
}
const nextPipeLabels = requirements.pipeLabels
? Array.from(labelById.values()).sort((a, b) =>
String(a.id).localeCompare(String(b.id)),
);
)
: [];
const nextPipeFragments = requirements.pipeFragments
? preparedPipeFragments
: [];
publishedSnapshotKeyRef.current = snapshotKey;
setJunctionDataState(nextJunctionData);
setPipeFragments(nextPipeFragments);
setPipeDataState(nextPipeLabels);
setElevationRange(
getNumericRange(nextJunctionData.map((item) => item.elevation)),
getNumericRange(
junctionRepresentatives.map(
(instance) => instance.properties.elevation || 0,
),
),
);
setDiameterRange(
getNumericRange(nextPipeLabels.map((item) => item.diameter)),
getNumericRange(
pipeRepresentatives.map(
(instance) => instance.properties.diameter || 0,
),
),
);
},
[buildPipeFragments],
[getPipeFragments],
);
useEffect(() => {
tileSnapshotSchedulerRef.current?.markDirty();
}, [
currentZoom,
currentPipeCalData.length,
isWaterflowLayerAvailable,
pipeText,
showContourLayer,
showJunctionId,
showJunctionTextLayer,
showPipeId,
showPipeTextLayer,
showWaterflowLayer,
]);
const operationalSources = useMemo(
() =>
createOperationalMapSources({
@@ -497,7 +510,7 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
if (isDisposingRef.current) return;
try {
junctionIndexRef.current?.registerTile(event.tile);
scheduleActiveTileSnapshot();
tileSnapshotScheduler.markDirty();
} catch (error) {
console.error("Junction tile load error:", error);
}
@@ -506,7 +519,7 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
if (isDisposingRef.current) return;
try {
pipeIndexRef.current?.registerTile(event.tile);
scheduleActiveTileSnapshot();
tileSnapshotScheduler.markDirty();
} catch (error) {
console.error("Pipe tile load error:", error);
}
@@ -534,13 +547,40 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
layers: operationalResources.orderedLayers.slice(),
controls: [],
});
const scheduleActiveTileSnapshot = debounce(
() => publishActiveTileSnapshot(map),
50,
);
const tileSnapshotScheduler = createTileSnapshotScheduler({
publish: () => publishActiveTileSnapshot(map),
wait: 100,
});
tileSnapshotSchedulerRef.current = tileSnapshotScheduler;
junctionSource.on("tileloadend", handleJunctionTileLoadEnd);
pipeSource.on("tileloadend", handlePipeTileLoadEnd);
map.getInteractions().forEach(markMapResourcePersistent);
// 缩放或平移期间只登记瓦片,视图稳定后统一生成 Deck 数据。
const handleMoveStart = () => {
tileSnapshotScheduler.beginMove();
};
const handleMoveEnd = () => {
if (isDisposingRef.current) return;
const view = map.getView();
const zoom = view.getZoom() || 0;
setCurrentZoom(zoom);
junctionIndexRef.current?.scanLoadedTiles();
pipeIndexRef.current?.scanLoadedTiles();
tileSnapshotScheduler.endMove();
try {
const center = view.getCenter();
if (center) {
localStorage.setItem(
MAP_VIEW_STORAGE_KEY,
JSON.stringify({ center, zoom }),
);
}
} catch (err) {
console.warn("Save map view failed", err);
}
};
map.on("movestart", handleMoveStart);
map.on("moveend", handleMoveEnd);
setMap(map);
// 恢复上次视图;如果没有则适配 MAP_EXTENT
@@ -577,29 +617,6 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
duration: 1000,
});
}
// 视图稳定后同步 Deck 数据并持久化,避免移动过程中重复扫描瓦片。
const handleViewChange = debounce(() => {
if (isDisposingRef.current) return;
const view = map.getView();
const zoom = view.getZoom() || 0;
setCurrentZoom(zoom);
junctionIndexRef.current?.scanLoadedTiles();
pipeIndexRef.current?.scanLoadedTiles();
scheduleActiveTileSnapshot();
try {
const center = view.getCenter();
if (center) {
localStorage.setItem(
MAP_VIEW_STORAGE_KEY,
JSON.stringify({ center, zoom }),
);
}
} catch (err) {
console.warn("Save map view failed", err);
}
}, 250);
map.getView().on("change", handleViewChange);
// 初始化当前缩放级别并强制触发瓦片加载
const initializeTimer = window.setTimeout(() => {
if (isDisposingRef.current) return;
@@ -607,7 +624,7 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
setCurrentZoom(initialZoom);
junctionIndexRef.current?.scanLoadedTiles();
pipeIndexRef.current?.scanLoadedTiles();
scheduleActiveTileSnapshot();
tileSnapshotScheduler.markDirty();
// 强制触发地图渲染,让瓦片加载事件触发
map.render();
}, 100);
@@ -637,11 +654,14 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
return () => {
isDisposingRef.current = true;
window.clearTimeout(initializeTimer);
scheduleActiveTileSnapshot.cancel();
handleViewChange.cancel();
tileSnapshotScheduler.cancel();
if (tileSnapshotSchedulerRef.current === tileSnapshotScheduler) {
tileSnapshotSchedulerRef.current = null;
}
junctionSource.un("tileloadend", handleJunctionTileLoadEnd);
pipeSource.un("tileloadend", handlePipeTileLoadEnd);
map.getView().un("change", handleViewChange);
map.un("movestart", handleMoveStart);
map.un("moveend", handleMoveEnd);
junctionsLayer.un("change:visible", handleJunctionVisibilityChange);
pipesLayer.un("change:visible", handlePipeVisibilityChange);
if (deckLayerRef.current && !deckLayerRef.current.isDisposedLayer()) {
@@ -659,6 +679,8 @@ const MapComponent: React.FC<MapComponentProps> = ({ children }) => {
disposeMapResources(map, { disposeLayers: false });
junctionIndexRef.current = null;
pipeIndexRef.current = null;
publishedSnapshotKeyRef.current = "";
pipeFragmentCacheRef.current = new WeakMap();
setJunctionDataState([]);
setPipeDataState([]);
setPipeFragments([]);
@@ -9,9 +9,11 @@ jest.mock("ol/proj", () => ({
import {
TileFeatureIndex,
buildPipeFeatureFragments,
clipLineStringPartsToExtent,
lineStringFromFlatCoordinates,
} from "./tileFeatureIndex";
import { along, bearing, lineString, length } from "@turf/turf";
describe("tileFeatureIndex geometry helpers", () => {
it("clips a line to the tile core extent without dropping both sides", () => {
@@ -98,4 +100,72 @@ describe("tileFeatureIndex geometry helpers", () => {
expect(snapshot.instances).toHaveLength(2);
expect(snapshot.instancesById.get("P-1")).toHaveLength(2);
});
it("does not reparse an unchanged loaded tile", () => {
const getFeatures = jest.fn(() => [
{
getProperties: () => ({ id: "P-1" }),
getGeometry: () => ({
getType: () => "LineString",
getFlatCoordinates: () => [0, 0, 10, 0],
getStride: () => 2,
}),
},
]);
const tile = {
getTileCoord: () => [14, 1, 5],
getRevision: () => 1,
getFeatures,
};
const source = {
sourceTiles_: { tile },
getTileGrid: () => ({
getTileCoordExtent: () => [0, -10, 10, 10],
}),
} as any;
const index = new TileFeatureIndex("pipes", source);
index.scanLoadedTiles();
index.scanLoadedTiles();
expect(getFeatures).toHaveBeenCalledTimes(1);
expect(index.getSnapshot(undefined, 14).signature).toContain(
"pipes/14/1/5@1",
);
});
it("builds pipe geometry with the same spherical measurements in one pass", () => {
const instance = {
instanceKey: "pipes/14/1/5/0",
z: 14,
featureId: "P-1",
properties: { diameter: 100 },
geometryType: "LineString",
tileExtent: [-1, -1, 1, 1] as [number, number, number, number],
flatCoordinates: [0, 0, 0.01, 0.005, 0.02, 0],
stride: 2,
};
const path = lineString([
[0, 0],
[0.01, 0.005],
[0.02, 0],
]);
const expectedLength = length(path);
const expectedMidpoint = along(path, expectedLength / 2).geometry
.coordinates;
const expectedPrevious = along(path, expectedLength * 0.49).geometry
.coordinates;
const expectedNext = along(path, expectedLength * 0.51).geometry
.coordinates;
let expectedAngle = -bearing(expectedPrevious, expectedNext) + 90;
if (expectedAngle < -90 || expectedAngle > 90) expectedAngle += 180;
const [fragment] = buildPipeFeatureFragments(instance);
expect(fragment.fragmentLength).toBeCloseTo(expectedLength, 10);
expect(fragment.position[0]).toBeCloseTo(expectedMidpoint[0], 10);
expect(fragment.position[1]).toBeCloseTo(expectedMidpoint[1], 10);
expect(fragment.angle).toBeCloseTo(expectedAngle, 6);
expect(fragment.timestamps.at(-1)).toBeCloseTo(10, 10);
});
});
+245 -8
View File
@@ -24,10 +24,34 @@ export type TileFeatureInstance = {
};
type TileFeatureSnapshot = {
signature: string;
instancesById: Map<string, TileFeatureInstance[]>;
instances: TileFeatureInstance[];
};
export type PipeFeatureFragment = {
instanceKey: string;
id: string;
diameter: number;
length: number;
path: [number, number][];
position: [number, number];
angle: number;
timestamps: number[];
fragmentLength: number;
};
type RegisteredTile = {
tile: any;
sourceRevision: string | number;
signatureRevision: string | number;
z: number;
};
const EARTH_RADIUS_KM = 6371.0088;
const DEGREES_TO_RADIANS = Math.PI / 180;
const RADIANS_TO_DEGREES = 180 / Math.PI;
const getTileCoord = (tile: any): [number, number, number] | null => {
const coord =
typeof tile.getTileCoord === "function"
@@ -215,11 +239,174 @@ export const coordinatesToLonLat = (
return [lon, lat];
});
const sphericalDistance = (
start: [number, number],
end: [number, number],
) => {
const startLatitude = start[1] * DEGREES_TO_RADIANS;
const endLatitude = end[1] * DEGREES_TO_RADIANS;
const latitudeDelta = (end[1] - start[1]) * DEGREES_TO_RADIANS;
const longitudeDelta = (end[0] - start[0]) * DEGREES_TO_RADIANS;
const haversine =
Math.sin(latitudeDelta / 2) ** 2 +
Math.sin(longitudeDelta / 2) ** 2 *
Math.cos(startLatitude) *
Math.cos(endLatitude);
const normalizedHaversine = Math.min(1, Math.max(0, haversine));
return (
2 *
EARTH_RADIUS_KM *
Math.atan2(
Math.sqrt(normalizedHaversine),
Math.sqrt(1 - normalizedHaversine),
)
);
};
const sphericalBearing = (
start: [number, number],
end: [number, number],
) => {
const startLatitude = start[1] * DEGREES_TO_RADIANS;
const endLatitude = end[1] * DEGREES_TO_RADIANS;
const longitudeDelta = (end[0] - start[0]) * DEGREES_TO_RADIANS;
return (
Math.atan2(
Math.sin(longitudeDelta) * Math.cos(endLatitude),
Math.cos(startLatitude) * Math.sin(endLatitude) -
Math.sin(startLatitude) *
Math.cos(endLatitude) *
Math.cos(longitudeDelta),
) * RADIANS_TO_DEGREES
);
};
const sphericalDestination = (
origin: [number, number],
distance: number,
bearing: number,
): [number, number] => {
const longitude = origin[0] * DEGREES_TO_RADIANS;
const latitude = origin[1] * DEGREES_TO_RADIANS;
const bearingRadians = bearing * DEGREES_TO_RADIANS;
const angularDistance = distance / EARTH_RADIUS_KM;
const destinationLatitude = Math.asin(
Math.sin(latitude) * Math.cos(angularDistance) +
Math.cos(latitude) *
Math.sin(angularDistance) *
Math.cos(bearingRadians),
);
const destinationLongitude =
longitude +
Math.atan2(
Math.sin(bearingRadians) *
Math.sin(angularDistance) *
Math.cos(latitude),
Math.cos(angularDistance) -
Math.sin(latitude) * Math.sin(destinationLatitude),
);
return [
destinationLongitude * RADIANS_TO_DEGREES,
destinationLatitude * RADIANS_TO_DEGREES,
];
};
const coordinateAtDistance = (
path: [number, number][],
cumulativeLengths: Float64Array,
targetDistance: number,
): [number, number] => {
let index = 1;
while (
index < cumulativeLengths.length &&
cumulativeLengths[index] < targetDistance
) {
index += 1;
}
if (index >= path.length) return path[path.length - 1];
if (cumulativeLengths[index] === targetDistance) return path[index];
const segmentStart = path[index - 1];
const remainingDistance =
targetDistance - cumulativeLengths[index - 1];
return sphericalDestination(
segmentStart,
remainingDistance,
sphericalBearing(segmentStart, path[index]),
);
};
export const buildPipeFeatureFragments = (
instance: TileFeatureInstance,
): PipeFeatureFragment[] => {
const tileCoordinates = lineStringFromFlatCoordinates(
instance.flatCoordinates,
instance.stride,
);
return clipLineStringPartsToExtent(
tileCoordinates,
instance.tileExtent,
).flatMap((clippedCoordinates, partIndex) => {
const path = coordinatesToLonLat(clippedCoordinates);
if (path.length < 2) return [];
const cumulativeLengths = new Float64Array(path.length);
let fragmentLength = 0;
for (let index = 1; index < path.length; index += 1) {
fragmentLength += sphericalDistance(path[index - 1], path[index]);
cumulativeLengths[index] = fragmentLength;
}
if (fragmentLength <= 0) return [];
const timestamps = new Array<number>(path.length);
timestamps[0] = 0;
for (let index = 1; index < path.length; index += 1) {
timestamps[index] =
(cumulativeLengths[index] / fragmentLength) * 10;
}
const previousPoint = coordinateAtDistance(
path,
cumulativeLengths,
fragmentLength * 0.49,
);
const position = coordinateAtDistance(
path,
cumulativeLengths,
fragmentLength * 0.5,
);
const nextPoint = coordinateAtDistance(
path,
cumulativeLengths,
fragmentLength * 0.51,
);
let angle = -sphericalBearing(previousPoint, nextPoint) + 90;
if (angle < -90 || angle > 90) angle += 180;
return [
{
instanceKey: `${instance.instanceKey}/${partIndex}`,
id: instance.featureId,
diameter: instance.properties.diameter || 0,
length: instance.properties.length || fragmentLength * 1000,
path,
position,
angle,
timestamps,
fragmentLength,
},
];
});
};
export class TileFeatureIndex {
private readonly instancesByTile = new Map<
TileKey,
TileFeatureInstance[]
>();
private readonly registeredTiles = new Map<TileKey, RegisteredTile>();
private readonly tileKeysByZoom = new Map<number, Set<TileKey>>();
private nextRevision = 0;
constructor(
private readonly sourceKey: string,
@@ -246,6 +433,19 @@ export class TileFeatureIndex {
if (!extent) return null;
const tileKey = getTileKey(this.sourceKey, z, x, y);
const tileRevision =
typeof tile.getRevision === "function"
? tile.getRevision()
: `${typeof tile.getState === "function" ? tile.getState() : ""}`;
const registeredTile = this.registeredTiles.get(tileKey);
if (
registeredTile &&
registeredTile.tile === tile &&
registeredTile.sourceRevision === tileRevision
) {
return tileKey;
}
const tileInstances: TileFeatureInstance[] = [];
const renderFeatures = tile.getFeatures() ?? [];
renderFeatures.forEach((renderFeature: any, featureOrdinal: number) => {
@@ -279,16 +479,39 @@ export class TileFeatureIndex {
if (tileInstances.length === 0) {
this.instancesByTile.delete(tileKey);
const zoomTileKeys = this.tileKeysByZoom.get(z);
zoomTileKeys?.delete(tileKey);
if (zoomTileKeys?.size === 0) this.tileKeysByZoom.delete(z);
} else {
this.instancesByTile.set(tileKey, tileInstances);
const zoomTileKeys = this.tileKeysByZoom.get(z);
if (zoomTileKeys) zoomTileKeys.add(tileKey);
else this.tileKeysByZoom.set(z, new Set([tileKey]));
}
this.nextRevision += 1;
this.registeredTiles.set(tileKey, {
tile,
sourceRevision: tileRevision,
signatureRevision:
tileRevision === "" ? `local-${this.nextRevision}` : tileRevision,
z,
});
return tileKey;
}
private pruneMissingTiles(activeTileKeys: Set<TileKey>) {
Array.from(this.instancesByTile.keys()).forEach((tileKey) => {
Array.from(this.registeredTiles.keys()).forEach((tileKey) => {
if (!activeTileKeys.has(tileKey)) {
const registration = this.registeredTiles.get(tileKey);
this.instancesByTile.delete(tileKey);
this.registeredTiles.delete(tileKey);
if (registration) {
const zoomTileKeys = this.tileKeysByZoom.get(registration.z);
zoomTileKeys?.delete(tileKey);
if (zoomTileKeys?.size === 0) {
this.tileKeysByZoom.delete(registration.z);
}
}
}
});
}
@@ -301,13 +524,21 @@ export class TileFeatureIndex {
? tileGrid.getZForResolution(resolution, (this.source as any).zDirection)
: Math.max(0, Math.round(zoom));
const viewExtent = map?.getView().calculateExtent(map.getSize());
const instances = Array.from(this.instancesByTile.values())
.flat()
.filter((instance) => instance.z === targetZ)
.filter(
(instance) =>
!viewExtent || intersects(instance.tileExtent, viewExtent),
const activeTileKeys = Array.from(
this.tileKeysByZoom.get(targetZ) ?? [],
)
.filter((tileKey) => {
const tileInstances = this.instancesByTile.get(tileKey);
return (
tileInstances &&
tileInstances.length > 0 &&
(!viewExtent ||
intersects(tileInstances[0].tileExtent, viewExtent))
);
})
.sort();
const instances = activeTileKeys
.flatMap((tileKey) => this.instancesByTile.get(tileKey) ?? [])
.sort((a, b) => a.instanceKey.localeCompare(b.instanceKey));
const instancesById = new Map<string, TileFeatureInstance[]>();
@@ -317,6 +548,12 @@ export class TileFeatureIndex {
else instancesById.set(instance.featureId, [instance]);
});
return { instancesById, instances };
const signature = `${targetZ}:${activeTileKeys
.map((tileKey) => {
const registration = this.registeredTiles.get(tileKey);
return `${tileKey}@${registration?.signatureRevision ?? "unknown"}`;
})
.join("|")}`;
return { signature, instancesById, instances };
}
}
@@ -0,0 +1,46 @@
import { createTileSnapshotScheduler } from "./tileSnapshotScheduler";
describe("createTileSnapshotScheduler", () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
it("defers and coalesces tile snapshots until movement ends", () => {
const publish = jest.fn();
const scheduler = createTileSnapshotScheduler({ publish, wait: 50 });
scheduler.beginMove();
scheduler.markDirty();
scheduler.markDirty();
jest.advanceTimersByTime(100);
expect(publish).not.toHaveBeenCalled();
scheduler.endMove();
jest.advanceTimersByTime(49);
expect(publish).not.toHaveBeenCalled();
jest.advanceTimersByTime(1);
expect(publish).toHaveBeenCalledTimes(1);
});
it("batches settled tile arrivals and cancels pending work", () => {
const publish = jest.fn();
const scheduler = createTileSnapshotScheduler({ publish, wait: 50 });
scheduler.markDirty();
jest.advanceTimersByTime(25);
scheduler.markDirty();
jest.advanceTimersByTime(49);
expect(publish).not.toHaveBeenCalled();
jest.advanceTimersByTime(1);
expect(publish).toHaveBeenCalledTimes(1);
scheduler.markDirty();
scheduler.cancel();
jest.runAllTimers();
expect(publish).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,56 @@
type TileSnapshotSchedulerOptions = {
publish: () => void;
wait?: number;
};
export type TileSnapshotScheduler = {
beginMove: () => void;
endMove: () => void;
markDirty: () => void;
cancel: () => void;
};
export const createTileSnapshotScheduler = ({
publish,
wait = 100,
}: TileSnapshotSchedulerOptions): TileSnapshotScheduler => {
let moving = false;
let dirty = false;
let timer: ReturnType<typeof setTimeout> | null = null;
const clearTimer = () => {
if (timer !== null) {
clearTimeout(timer);
timer = null;
}
};
const schedule = () => {
dirty = true;
if (moving) return;
clearTimer();
timer = setTimeout(() => {
timer = null;
if (moving || !dirty) return;
dirty = false;
publish();
}, wait);
};
return {
beginMove: () => {
moving = true;
clearTimer();
},
endMove: () => {
moving = false;
schedule();
},
markDirty: schedule,
cancel: () => {
moving = false;
dirty = false;
clearTimer();
},
};
};