diff --git a/src/components/olmap/core/MapComponent.tsx b/src/components/olmap/core/MapComponent.tsx index 9be03b9..3ec976b 100644 --- a/src/components/olmap/core/MapComponent.tsx +++ b/src/components/olmap/core/MapComponent.tsx @@ -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(undefined); const DataContext = createContext(undefined); -// 添加防抖函数 -type DebouncedFunction any> = (( - ...args: Parameters -) => void) & { - cancel: () => void; -}; - -function debounce any>( - func: F, - waitFor: number -): DebouncedFunction { - let timeout: ReturnType | null = null; - - const debounced = (...args: Parameters): 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 = ({ children }) => { const compareCanvasRef = useRef(null); const deckLayerRef = useRef(null); const compareDeckLayerRef = useRef(null); + const tileSnapshotSchedulerRef = useRef(null); + const publishedSnapshotKeyRef = useRef(""); + const pipeFragmentCacheRef = useRef( + new WeakMap(), + ); const isDisposingRef = useRef(false); const isCompareDisposingRef = useRef(false); @@ -279,6 +257,30 @@ const MapComponent: React.FC = ({ 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 = ({ 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,60 +364,113 @@ const MapComponent: React.FC = ({ 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) - .map((instance) => { - const [x, y] = lineStringFromFlatCoordinates( - instance.flatCoordinates, - instance.stride, - )[0]; - return { - id: instance.featureId, - instanceKey: instance.instanceKey, - position: toLonLat([x, y]), - elevation: instance.properties.elevation || 0, - demand: instance.properties.demand || 0, - }; - }) - .sort((a, b) => String(a.id).localeCompare(String(b.id))); + .filter(Boolean); + const pipeRepresentatives = Array.from( + pipeSnapshot.instancesById.values(), + ) + .map((instances) => instances[0]) + .filter(Boolean); - const nextPipeFragments = (pipeSnapshot?.instances ?? []) - .filter((instance) => instance.geometryType.includes("Line")) - .flatMap(buildPipeFragments) - .sort((a, b) => a.instanceKey.localeCompare(b.instanceKey)); + const nextJunctionData = requirements.junctionData + ? junctionRepresentatives + .map((instance) => { + const [x, y] = lineStringFromFlatCoordinates( + instance.flatCoordinates, + instance.stride, + )[0]; + return { + id: instance.featureId, + instanceKey: instance.instanceKey, + position: toLonLat([x, y]), + elevation: instance.properties.elevation || 0, + demand: instance.properties.demand || 0, + }; + }) + .sort((a, b) => String(a.id).localeCompare(String(b.id))) + : []; + + const preparedPipeFragments = + requirements.pipeLabels || requirements.pipeFragments + ? pipeSnapshot.instances + .filter((instance) => instance.geometryType.includes("Line")) + .flatMap(getPipeFragments) + .sort((a, b) => a.instanceKey.localeCompare(b.instanceKey)) + : []; const labelById = new Map(); - nextPipeFragments.forEach((fragment) => { - const previous = labelById.get(fragment.id); - if ( - !previous || - fragment.fragmentLength > previous.fragmentLength || - (fragment.fragmentLength === previous.fragmentLength && - fragment.instanceKey.localeCompare(previous.instanceKey) < 0) - ) { - labelById.set(fragment.id, fragment); - } - }); - const nextPipeLabels = Array.from(labelById.values()).sort((a, b) => - String(a.id).localeCompare(String(b.id)), - ); + if (requirements.pipeLabels) { + preparedPipeFragments.forEach((fragment) => { + const previous = labelById.get(fragment.id); + if ( + !previous || + fragment.fragmentLength > previous.fragmentLength || + (fragment.fragmentLength === previous.fragmentLength && + fragment.instanceKey.localeCompare(previous.instanceKey) < 0) + ) { + labelById.set(fragment.id, fragment); + } + }); + } + 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ children }) => { setCurrentZoom(initialZoom); junctionIndexRef.current?.scanLoadedTiles(); pipeIndexRef.current?.scanLoadedTiles(); - scheduleActiveTileSnapshot(); + tileSnapshotScheduler.markDirty(); // 强制触发地图渲染,让瓦片加载事件触发 map.render(); }, 100); @@ -637,11 +654,14 @@ const MapComponent: React.FC = ({ 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 = ({ children }) => { disposeMapResources(map, { disposeLayers: false }); junctionIndexRef.current = null; pipeIndexRef.current = null; + publishedSnapshotKeyRef.current = ""; + pipeFragmentCacheRef.current = new WeakMap(); setJunctionDataState([]); setPipeDataState([]); setPipeFragments([]); diff --git a/src/components/olmap/core/tileFeatureIndex.test.ts b/src/components/olmap/core/tileFeatureIndex.test.ts index 617533d..1c5e7d4 100644 --- a/src/components/olmap/core/tileFeatureIndex.test.ts +++ b/src/components/olmap/core/tileFeatureIndex.test.ts @@ -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); + }); }); diff --git a/src/components/olmap/core/tileFeatureIndex.ts b/src/components/olmap/core/tileFeatureIndex.ts index c941076..f333e31 100644 --- a/src/components/olmap/core/tileFeatureIndex.ts +++ b/src/components/olmap/core/tileFeatureIndex.ts @@ -24,10 +24,34 @@ export type TileFeatureInstance = { }; type TileFeatureSnapshot = { + signature: string; instancesById: Map; 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(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(); + private readonly tileKeysByZoom = new Map>(); + 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) { - 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(); @@ -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 }; } } diff --git a/src/components/olmap/core/tileSnapshotScheduler.test.ts b/src/components/olmap/core/tileSnapshotScheduler.test.ts new file mode 100644 index 0000000..a33334b --- /dev/null +++ b/src/components/olmap/core/tileSnapshotScheduler.test.ts @@ -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); + }); +}); diff --git a/src/components/olmap/core/tileSnapshotScheduler.ts b/src/components/olmap/core/tileSnapshotScheduler.ts new file mode 100644 index 0000000..032f091 --- /dev/null +++ b/src/components/olmap/core/tileSnapshotScheduler.ts @@ -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 | 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(); + }, + }; +};