perf(map): reduce tile snapshot work during zoom
This commit is contained in:
@@ -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,60 +364,113 @@ 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)
|
||||
.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<string, any>();
|
||||
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<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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user