diff --git a/src/gis/ol/flexpartContour/flexpartContour.worker.ts b/src/gis/ol/flexpartContour/flexpartContour.worker.ts new file mode 100644 index 00000000..76d97227 --- /dev/null +++ b/src/gis/ol/flexpartContour/flexpartContour.worker.ts @@ -0,0 +1,42 @@ +import { contours as createContours } from 'd3'; +import type { + ContourWorkerRequest, + ContourWorkerResponse, + SerializedContour, +} from './flexpartContourTypes'; + +// Keep the worker independent from the DOM and OpenLayers. +const workerScope = self as unknown as { + onmessage: ((event: MessageEvent) => void) | null; + postMessage: (message: ContourWorkerResponse) => void; +}; + +workerScope.onmessage = (event) => { + const request = event.data; + + try { + const generated = createContours() + .size([request.width, request.height]) + .thresholds(request.thresholds) + .smooth(true)(request.values as unknown as number[]); + + const response: ContourWorkerResponse = { + id: request.id, + contours: generated.map((item) => { + const contour = item as unknown as SerializedContour; + return { + value: contour.value, + coordinates: contour.coordinates, + }; + }), + }; + + workerScope.postMessage(response); + } + catch (error) { + workerScope.postMessage({ + id: request.id, + error: error instanceof Error ? error.message : String(error), + }); + } +}; diff --git a/src/gis/ol/flexpartContour/flexpartContourData.ts b/src/gis/ol/flexpartContour/flexpartContourData.ts new file mode 100644 index 00000000..f2f51382 --- /dev/null +++ b/src/gis/ol/flexpartContour/flexpartContourData.ts @@ -0,0 +1,119 @@ +import type { + FlexpartColorScale, + FlexpartGridAxes, + FlexpartGridFrame, + FlexpartGridPoint, +} from './flexpartContourTypes'; + +/** + * Expands one sparse backend response into the dense row-major grid required + * by Marching Squares. Grid positions absent from data.points remain zero. + * + * colorScale is passed separately so one scale can be reused across frames. + */ +export function createGridFrameFromSparsePoints( + points: readonly FlexpartGridPoint[], + axes: FlexpartGridAxes, + colorScale: FlexpartColorScale, + validTime?: string, +): FlexpartGridFrame { + if (!Array.isArray(points)) { + throw new Error('Frame point data must be an array.'); + } + + if (!axes) { + throw new Error('Complete longitude and latitude axes are required.'); + } + + const [longitudes, longitudeIndexes] = prepareAxis( + axes.longitudes, + 'longitudes', + ); + const [latitudes, latitudeIndexes] = prepareAxis( + axes.latitudes, + 'latitudes', + ); + const values = new Float64Array( + longitudes.length * latitudes.length, + ); + const occupied = new Uint8Array(values.length); + + points.forEach((point, pointIndex) => { + if (!point) { + throw new Error(`Point ${pointIndex} must be an object.`); + } + + if (!Number.isFinite(point.lon) + || !Number.isFinite(point.lat) + || !Number.isFinite(point.value)) { + throw new Error( + `Point ${pointIndex} must contain finite lon, lat and value numbers.`, + ); + } + + const longitudeIndex = longitudeIndexes.get(point.lon); + const latitudeIndex = latitudeIndexes.get(point.lat); + + if (longitudeIndex === undefined || latitudeIndex === undefined) { + throw new Error( + `Point lon=${point.lon}, lat=${point.lat} is not present in the axes.`, + ); + } + + const gridIndex = latitudeIndex * longitudes.length + longitudeIndex; + if (occupied[gridIndex] === 1) { + throw new Error( + `Duplicate point found at lon=${point.lon}, lat=${point.lat}.`, + ); + } + + occupied[gridIndex] = 1; + // Missing positions and accidentally returned nonpositive values stay zero. + if (point.value > 0) { + values[gridIndex] = point.value; + } + }); + + return { + longitudes, + latitudes, + values, + colorMin: colorScale.colorMin, + colorMax: colorScale.colorMax, + levels: colorScale.levels, + validTime, + }; +} + +function prepareAxis( + axis: readonly number[], + name: string, +): [number[], Map] { + if (!Array.isArray(axis) || axis.length < 2) { + throw new Error(`${name} must contain at least two coordinates.`); + } + + const coordinates = Array.from(axis); + const indexes = new Map(); + let direction = 0; + + coordinates.forEach((coordinate, index) => { + if (!Number.isFinite(coordinate)) { + throw new Error(`${name}[${index}] must be a finite number.`); + } + + if (index > 0) { + const difference = coordinate - coordinates[index - 1]; + const currentDirection = Math.sign(difference); + if (currentDirection === 0 + || (direction !== 0 && currentDirection !== direction)) { + throw new Error(`${name} must be strictly monotonic.`); + } + direction = currentDirection; + } + + indexes.set(coordinate, index); + }); + + return [coordinates, indexes]; +} diff --git a/src/gis/ol/flexpartContour/flexpartContourExample.ts b/src/gis/ol/flexpartContour/flexpartContourExample.ts new file mode 100644 index 00000000..1a035700 --- /dev/null +++ b/src/gis/ol/flexpartContour/flexpartContourExample.ts @@ -0,0 +1,56 @@ +import { + addFlexpartContourLayer, +} from './flexpartContourLayer'; +import { createGridFrameFromSparsePoints } from './flexpartContourData'; +import type { + FlexpartColorScale, + FlexpartContourLayerOptions, + FlexpartContourLayerResult, + FlexpartGridAxes, + FlexpartGridPoint, +} from './flexpartContourTypes'; + +/** Render already-loaded sparse data directly without sending a request. */ +export async function renderBackwardData( + points: readonly FlexpartGridPoint[], + axes: FlexpartGridAxes, + colorScale: FlexpartColorScale, + options: FlexpartContourLayerOptions = {}, +): Promise { + const frame = createGridFrameFromSparsePoints( + points, + axes, + colorScale, + ); + + return addFlexpartContourLayer(frame, options); +} + +/** + * The frame API only returns nonzero points. Complete axes and the color + * scale are obtained separately and reused across frame requests. + */ +export async function renderBackwardFrame( + time: string, + axes: FlexpartGridAxes, + colorScale: FlexpartColorScale, +): Promise { + const response = await fetch( + `/api/flexpart/backward/frame?time=${encodeURIComponent(time)}`, + ); + + if (!response.ok) { + throw new Error(`FLEXPART frame request failed: ${response.status}`); + } + + const points = await response.json() as FlexpartGridPoint[]; + await renderBackwardData( + points, + axes, + colorScale, + { + opacity: 0.78, + layerName: 'flexpart-contour-layer', + }, + ); +} diff --git a/src/gis/ol/flexpartContour/flexpartContourLayer.ts b/src/gis/ol/flexpartContour/flexpartContourLayer.ts new file mode 100644 index 00000000..62f4e909 --- /dev/null +++ b/src/gis/ol/flexpartContour/flexpartContourLayer.ts @@ -0,0 +1,612 @@ +import type OlMap from 'ol/Map'; +import type { Extent } from 'ol/extent'; +import { getWidth } from 'ol/extent'; +import ImageLayer from 'ol/layer/Image'; +import ImageCanvas from 'ol/source/ImageCanvas'; +import { transform, transformExtent } from 'ol/proj'; +import { obtainViewer2D } from '@/gis/ol/map'; +import type { + ContourWorkerRequest, + ContourWorkerResponse, + FlexpartContourLayerOptions, + FlexpartContourLayerResult, + FlexpartGridFrame, + SerializedContour, +} from './flexpartContourTypes'; +import { PYTHON_CONTOUR_COLORS } from './flexpartContourTypes'; + +const WEB_MERCATOR_MAX_LATITUDE = 85.0511287798066; + +interface ProjectedContour { + color: string; + path: Path2D; +} + +interface PendingRequest { + resolve: (value: SerializedContour[]) => void; + reject: (reason?: unknown) => void; +} + +interface PreparedContourGrid { + longitudes: readonly number[]; + values: Float64Array; +} + +export function createLogLevels( + colorMin: number, + colorMax: number, + bandCount = 10, +): number[] { + if (!Number.isFinite(colorMin) + || !Number.isFinite(colorMax) + || colorMin <= 0 + || colorMax <= colorMin + || bandCount < 1) { + throw new Error('Invalid logarithmic color range.'); + } + + const minExponent = Math.log10(colorMin); + const maxExponent = Math.log10(colorMax); + + return Array.from({ length: bandCount + 1 }, (_, index) => ( + 10 ** ( + minExponent + + (maxExponent - minExponent) * index / bandCount + ) + )); +} + +export class FlexpartContourRenderer { + private readonly map: OlMap; + + private readonly source: ImageCanvas; + + private readonly layer: ImageLayer; + + private readonly worker: Worker; + + private readonly pending = new Map(); + + private requestId = 0; + + private frameVersion = 0; + + private projectedContours: ProjectedContour[] = []; + + private layerExtent?: Extent; + + private readonly colors: readonly string[]; + + private readonly wrapX: boolean; + + private readonly worldWidth?: number; + + constructor( + map: OlMap, + options: FlexpartContourLayerOptions = {}, + ) { + this.map = map; + this.colors = options.colors ?? PYTHON_CONTOUR_COLORS; + this.wrapX = options.wrapX ?? true; + + const projection = map.getView().getProjection(); + const projectionCode = projection.getCode(); + const projectionExtent = projection.getExtent(); + this.worldWidth = this.wrapX + && projection.canWrapX() + && projectionExtent + ? getWidth(projectionExtent) + : undefined; + + this.source = new ImageCanvas({ + projection: projectionCode, + ratio: 1, + canvasFunction: ( + extent, + _resolution, + _pixelRatio, + size, + ) => this.drawCanvas(extent, size), + }); + + this.layer = new ImageLayer({ + source: this.source, + opacity: options.opacity ?? 0.78, + zIndex: options.zIndex ?? 1000, + }); + + if (options.layerName) { + this.layer.set('name', options.layerName); + } + + this.worker = new Worker( + new URL('./flexpartContour.worker.ts', import.meta.url), + { type: 'module' }, + ); + + this.worker.onmessage = ( + event: MessageEvent, + ) => { + const request = this.pending.get(event.data.id); + if (!request) { + return; + } + + this.pending.delete(event.data.id); + + if (event.data.error) { + request.reject(new Error(event.data.error)); + } + else { + request.resolve(event.data.contours ?? []); + } + }; + + this.worker.onerror = (event) => { + const error = new Error( + event.message || 'Contour worker failed.', + ); + this.pending.forEach((request) => request.reject(error)); + this.pending.clear(); + }; + + map.addLayer(this.layer); + } + + async setFrame( + frame: FlexpartGridFrame, + ): Promise { + this.validateFrame(frame); + + const version = ++this.frameVersion; + const levels = this.resolveLevels(frame); + const normalizedValues = this.prepareValues(frame); + const contourGrid = this.prepareCyclicLongitudeGrid( + frame.longitudes, + frame.latitudes.length, + normalizedValues, + ); + const rawContours = await this.calculateContours( + contourGrid.longitudes.length, + frame.latitudes.length, + contourGrid.values, + levels.slice(0, -1), + ); + + if (version !== this.frameVersion) { + throw new Error('A newer FLEXPART frame superseded this frame.'); + } + + const projection = this.map.getView().getProjection(); + this.projectedContours = rawContours.map((contour, index) => ({ + color: this.colors[index], + path: this.createProjectedPath( + contour, + contourGrid.longitudes, + frame.latitudes, + projection.getCode(), + ), + })); + + this.layerExtent = this.createLayerExtent( + frame.longitudes, + frame.latitudes, + projection.getCode(), + ); + // A wrapped global layer must not be clipped to the canonical world. + this.layer.setExtent(this.worldWidth ? undefined : this.layerExtent); + this.layer.setVisible(true); + this.source.changed(); + this.map.render(); + + return { + layerExtent: this.layerExtent, + levels, + validTime: frame.validTime, + }; + } + + getLayer(): ImageLayer { + return this.layer; + } + + setVisible(visible: boolean): void { + this.layer.setVisible(visible); + } + + setOpacity(opacity: number): void { + this.layer.setOpacity(Math.max(0, Math.min(1, opacity))); + } + + clear(): void { + this.projectedContours = []; + this.source.changed(); + } + + destroy(): void { + this.worker.terminate(); + this.map.removeLayer(this.layer); + } + + private validateFrame(frame: FlexpartGridFrame): void { + const width = frame.longitudes.length; + const height = frame.latitudes.length; + + if (width < 2 || height < 2) { + throw new Error('Longitude and latitude need at least two points.'); + } + + if (isNestedValues(frame.values)) { + if (frame.values.length !== height + || frame.values.some((row) => row.length !== width)) { + throw new Error( + `values must be a ${height}x${width} two-dimensional array.`, + ); + } + } + else if (frame.values.length !== width * height) { + throw new Error( + `values length must be ${width * height}, got ${frame.values.length}.`, + ); + } + + this.validateAxis(frame.longitudes, 'longitudes'); + this.validateAxis(frame.latitudes, 'latitudes'); + + if (!Number.isFinite(frame.colorMin) + || !Number.isFinite(frame.colorMax) + || frame.colorMin <= 0 + || frame.colorMax <= frame.colorMin) { + throw new Error('Invalid colorMin/colorMax.'); + } + } + + private validateAxis(axis: readonly number[], name: string): void { + let direction = 0; + + for (let index = 0; index < axis.length; index++) { + if (!Number.isFinite(axis[index])) { + throw new Error(`${name} contains a non-finite coordinate.`); + } + + if (index === 0) { + continue; + } + + const difference = axis[index] - axis[index - 1]; + const currentDirection = Math.sign(difference); + + if (currentDirection === 0 + || (direction !== 0 && currentDirection !== direction)) { + throw new Error(`${name} must be strictly monotonic.`); + } + + direction = currentDirection; + } + } + + private resolveLevels(frame: FlexpartGridFrame): number[] { + const levels = frame.levels + ? Array.from(frame.levels) + : createLogLevels(frame.colorMin, frame.colorMax); + + if (levels.length !== this.colors.length + 1) { + throw new Error( + `levels must contain ${this.colors.length + 1} values.`, + ); + } + + for (let index = 0; index < levels.length; index++) { + if (!Number.isFinite(levels[index]) + || levels[index] <= 0 + || (index > 0 && levels[index] <= levels[index - 1])) { + throw new Error('levels must be positive and strictly increasing.'); + } + } + + return levels; + } + + private prepareValues(frame: FlexpartGridFrame): Float64Array { + const width = frame.longitudes.length; + const height = frame.latitudes.length; + const values = new Float64Array(width * height); + + for (let row = 0; row < height; row++) { + for (let column = 0; column < width; column++) { + const sourceValue = isNestedValues(frame.values) + ? frame.values[row][column] + : frame.values[row * width + column]; + const value = Number(sourceValue); + + // Positive values below colorMin still influence contour interpolation. + // The first threshold keeps those cells transparent without log10 data. + values[row * width + column] = Number.isFinite(value) && value > 0 + ? value + : Number.NaN; + } + } + + return values; + } + + private prepareCyclicLongitudeGrid( + longitudes: readonly number[], + height: number, + values: Float64Array, + ): PreparedContourGrid { + if (!this.wrapX || !isGlobalCyclicAxis(longitudes)) { + return { longitudes, values }; + } + + const sourceWidth = longitudes.length; + const targetWidth = sourceWidth + 1; + const direction = Math.sign( + longitudes[sourceWidth - 1] - longitudes[0], + ); + const cyclicLongitudes = [ + ...longitudes, + longitudes[0] + direction * 360, + ]; + const cyclicValues = new Float64Array(targetWidth * height); + + for (let row = 0; row < height; row++) { + const sourceOffset = row * sourceWidth; + const targetOffset = row * targetWidth; + cyclicValues.set( + values.subarray(sourceOffset, sourceOffset + sourceWidth), + targetOffset, + ); + cyclicValues[targetOffset + sourceWidth] = values[sourceOffset]; + } + + return { + longitudes: cyclicLongitudes, + values: cyclicValues, + }; + } + + private calculateContours( + width: number, + height: number, + values: Float64Array, + thresholds: number[], + ): Promise { + const id = ++this.requestId; + + return new Promise((resolve, reject) => { + this.pending.set(id, { resolve, reject }); + + const request: ContourWorkerRequest = { + id, + width, + height, + values, + thresholds, + }; + + this.worker.postMessage(request, [ + values.buffer, + ]); + }); + } + + private createProjectedPath( + contour: SerializedContour, + longitudes: readonly number[], + latitudes: readonly number[], + projectionCode: string, + ): Path2D { + const path = new Path2D(); + + contour.coordinates.forEach((polygon) => { + polygon.forEach((ring) => { + ring.forEach(([gridX, gridY], pointIndex) => { + const lon = interpolateGridCoordinate(longitudes, gridX); + const lat = interpolateGridCoordinate(latitudes, gridY); + const projected = transform( + [lon, clampLatitude(lat)], + 'EPSG:4326', + projectionCode, + ); + + if (pointIndex === 0) { + path.moveTo(projected[0], projected[1]); + } + else { + path.lineTo(projected[0], projected[1]); + } + }); + + path.closePath(); + }); + }); + + return path; + } + + private createLayerExtent( + longitudes: readonly number[], + latitudes: readonly number[], + projectionCode: string, + ): Extent { + const lonMin = interpolateGridCoordinate(longitudes, 0); + const lonMax = interpolateGridCoordinate(longitudes, longitudes.length); + const latMin = interpolateGridCoordinate(latitudes, 0); + const latMax = interpolateGridCoordinate(latitudes, latitudes.length); + + return transformExtent( + [ + Math.min(lonMin, lonMax), + clampLatitude(Math.min(latMin, latMax)), + Math.max(lonMin, lonMax), + clampLatitude(Math.max(latMin, latMax)), + ], + 'EPSG:4326', + projectionCode, + ); + } + + private drawCanvas( + extent: Extent, + size: readonly number[], + ): HTMLCanvasElement { + const canvas = document.createElement('canvas'); + canvas.width = Math.max(1, Math.round(size[0])); + canvas.height = Math.max(1, Math.round(size[1])); + + const context = canvas.getContext('2d'); + if (!context || extent[2] <= extent[0] || extent[3] <= extent[1]) { + return canvas; + } + + context.clearRect(0, 0, canvas.width, canvas.height); + + const scaleX = canvas.width / (extent[2] - extent[0]); + const scaleY = canvas.height / (extent[3] - extent[1]); + + context.setTransform( + scaleX, + 0, + 0, + -scaleY, + -extent[0] * scaleX, + extent[3] * scaleY, + ); + + const worldOffsets = this.worldWidth + ? [-this.worldWidth, 0, this.worldWidth] + : [0]; + + // Lower thresholds are drawn first; higher thresholds cover them. + this.projectedContours.forEach((contour) => { + context.fillStyle = contour.color; + worldOffsets.forEach((offset) => { + context.save(); + context.translate(offset, 0); + context.fill(contour.path, 'evenodd'); + context.restore(); + }); + }); + + context.setTransform(1, 0, 0, 1, 0, 0); + return canvas; + } +} + +let defaultRenderer: FlexpartContourRenderer | undefined; + +/** Direct adapter for the existing olContainer map. */ +export async function addFlexpartContourLayer( + frame: FlexpartGridFrame, + options: FlexpartContourLayerOptions = {}, +): Promise { + const map = obtainViewer2D('olContainer') as OlMap | undefined; + + if (!map) { + throw new Error('OpenLayers map olContainer is not initialized.'); + } + + if (!defaultRenderer) { + defaultRenderer = new FlexpartContourRenderer(map, options); + } + + return defaultRenderer.setFrame(frame); +} + +export function clearFlexpartContourLayer(): void { + defaultRenderer?.clear(); +} + +export function destroyFlexpartContourLayer(): void { + defaultRenderer?.destroy(); + defaultRenderer = undefined; +} + +/** Move the existing olContainer view to a Pacific-centered longitude. */ +// export function centerFlexpartMap( +// longitude = 180, +// latitude = 0, +// duration = 0, +// ): void { +// const map = obtainViewer2D('olContainer') as OlMap | undefined; + +// if (!map) { +// throw new Error('OpenLayers map olContainer is not initialized.'); +// } + +// if (!Number.isFinite(longitude) +// || !Number.isFinite(latitude) +// || !Number.isFinite(duration) +// || duration < 0) { +// throw new Error('Invalid map center or animation duration.'); +// } + +// const center = transform( +// [longitude, clampLatitude(latitude)], +// 'EPSG:4326', +// map.getView().getProjection(), +// ); + +// if (duration > 0) { +// map.getView().animate({ center, duration }); +// } +// else { +// map.getView().setCenter(center); +// } +// } + +function interpolateGridCoordinate( + coordinates: readonly number[], + gridPosition: number, +): number { + const centerPosition = gridPosition - 0.5; + const lastIndex = coordinates.length - 1; + + if (centerPosition <= 0) { + return coordinates[0] + + centerPosition * (coordinates[1] - coordinates[0]); + } + + if (centerPosition >= lastIndex) { + return coordinates[lastIndex] + + (centerPosition - lastIndex) + * (coordinates[lastIndex] - coordinates[lastIndex - 1]); + } + + const lowerIndex = Math.floor(centerPosition); + const ratio = centerPosition - lowerIndex; + + return coordinates[lowerIndex] + + (coordinates[lowerIndex + 1] - coordinates[lowerIndex]) * ratio; +} + +function clampLatitude(latitude: number): number { + return Math.max( + -WEB_MERCATOR_MAX_LATITUDE, + Math.min(WEB_MERCATOR_MAX_LATITUDE, latitude), + ); +} + +function isNestedValues( + values: FlexpartGridFrame['values'], +): values is readonly (readonly number[])[] { + return Array.isArray(values) + && values.length > 0 + && Array.isArray(values[0]); +} + +function isGlobalCyclicAxis(longitudes: readonly number[]): boolean { + if (longitudes.length < 3) { + return false; + } + + const span = Math.abs( + longitudes[longitudes.length - 1] - longitudes[0], + ); + const averageStep = span / (longitudes.length - 1); + const coverage = span + averageStep; + const tolerance = Math.max(1e-6, averageStep * 0.1); + + return Math.abs(coverage - 360) <= tolerance; +} diff --git a/src/gis/ol/flexpartContour/flexpartContourTypes.ts b/src/gis/ol/flexpartContour/flexpartContourTypes.ts new file mode 100644 index 00000000..bb8f892a --- /dev/null +++ b/src/gis/ol/flexpartContour/flexpartContourTypes.ts @@ -0,0 +1,92 @@ +import type { Extent } from 'ol/extent'; + +/** One nonzero grid item returned by the frame-data API. */ +export interface FlexpartGridPoint { + lon: number; + lat: number; + value: number; +} + +/** + * Complete coordinate axes obtained separately and reused across frames. + */ +export interface FlexpartGridAxes { + longitudes: readonly number[]; + latitudes: readonly number[]; +} + +/** + * Color scale calculated separately from frame data and reused across frames. + * All values use the same physical unit as FlexpartGridPoint.value. + */ +export interface FlexpartColorScale { + colorMin: number; + colorMax: number; + /** Optional. When absent, 10 logarithmic bands are generated. */ + levels?: readonly number[]; +} + +/** + * FLEXPART frame contract. + * Values may be a two-dimensional array [latitude][longitude] or a flattened + * row-major array: values[latIndex * lonCount + lonIndex]. + * Values must already be converted to the displayed physical unit. + * Do not send log10(values); the renderer uses logarithmic thresholds. + */ +export interface FlexpartGridFrame { + longitudes: readonly number[]; + latitudes: readonly number[]; + values: ArrayLike | readonly (readonly number[])[]; + colorMin: number; + colorMax: number; + /** Optional. When absent, 10 logarithmic bands are generated. */ + levels?: readonly number[]; + validTime?: string; +} + +export interface FlexpartContourLayerOptions { + opacity?: number; + zIndex?: number; + colors?: readonly string[]; + layerName?: string; + /** Draw global contours in adjacent wrapped worlds. Defaults to true. */ + wrapX?: boolean; +} + +export interface SerializedContour { + value: number; + coordinates: number[][][][]; +} + +export interface ContourWorkerRequest { + id: number; + width: number; + height: number; + values: Float64Array; + thresholds: number[]; +} + +export interface ContourWorkerResponse { + id: number; + contours?: SerializedContour[]; + error?: string; +} + +export interface FlexpartContourLayerResult { + layerExtent: Extent; + levels: number[]; + validTime?: string; +} + +export const PYTHON_CONTOUR_COLORS = [ + 'bisque', + 'tan', + 'lightblue', + 'dodgerblue', + 'lightgreen', + 'green', + 'yellow', + 'orange', + 'red', + 'darkred', +] as const; diff --git a/src/gis/ol/index.ts b/src/gis/ol/index.ts index 8a4064ac..f5de2e95 100644 --- a/src/gis/ol/index.ts +++ b/src/gis/ol/index.ts @@ -17,6 +17,8 @@ import { windDataHandle } from './tool/handle/windDataHandle'; import { addHeadMap, removeHeadMap } from './tool/heatMap'; import { addWeathLayer, removeWeathLayer, setWeathLayerVisity } from './tool/weath'; import { addWindLayer, removeWindLayer, setWindLayerVisity } from './tool/wind'; +import { clearFlexpartContourLayer } from './flexpartContour/flexpartContourLayer'; + // 当前模块信息 let activeMoudleInf: any = { @@ -128,6 +130,10 @@ const palyDateEvent: any = { setShouldAnimate(false); // 清理自定义热力图 clearGridFeatures(); + + + // 清空画面,图层保留 + clearFlexpartContourLayer(); // 清理热力图 clearHeatmapFeatures(); // 移除标记点 diff --git a/src/gis/ol/map.ts b/src/gis/ol/map.ts index b85a1345..e1ea5aeb 100644 --- a/src/gis/ol/map.ts +++ b/src/gis/ol/map.ts @@ -82,7 +82,9 @@ export function initMap2D(id: string, options: MapOptions = {}): void { }); // 创建视图实例 - const view = new View(cenView); + const view = new View({ + ...cenView, + }); // 创建地图实例 const map = new Map({ diff --git a/src/gis/worker/playbackWorker.ts b/src/gis/worker/playbackWorker.ts index e0ba1159..a83fa641 100644 --- a/src/gis/worker/playbackWorker.ts +++ b/src/gis/worker/playbackWorker.ts @@ -1,22 +1,22 @@ function initPlaybackWorker() { const queryApi = { symn: { - url: '/transportResult/getDiffusionData', - method: 'GET', + url: "/transportResult/getDiffusionData", + method: "GET", }, hsjmnNDFB: { - url: '/diffusionData/getDiffusionResult', - method: 'GET', + url: "/diffusionData/getDiffusionResult", + method: "GET", }, hsjmnJLFB: { - url: '/doseData/getDoseResult', - method: 'GET', + url: "/doseData/getDoseResult", + method: "GET", }, }; - let serverIp = ''; - let token = ''; - const baseURL = '/api'; + let serverIp = ""; + let token = ""; + const baseURL = "/api"; let symnNum = 0; // 当前请求次数(从 0 开始) let symmMaxNum = 30; // 总循环次数 const symnResult = new Map(); // 存储所有请求结果 @@ -34,6 +34,9 @@ function initPlaybackWorker() { let lastTargetTs = null; // 关键:记录上一次匹配的时间戳(初始为 null) let lonWidth = null; let latWidth = null; + let latGroupData = null; + let lonGroupData = null; + /** * 二分查找:找到当前时间对应的目标时间戳 @@ -41,17 +44,15 @@ function initPlaybackWorker() { * @returns 匹配的时间戳(无匹配返回 null) */ function findTargetTimestamp(currentTime) { - if (!timeList || timeList.length === 0) - return null; + if (!timeList || timeList.length === 0) return null; const len = timeList.length; - if (currentTime <= timeList[0]) - return timeList[0]; - if (currentTime >= timeList[len - 1]) - return timeList[len - 1]; + if (currentTime <= timeList[0]) return timeList[0]; + if (currentTime >= timeList[len - 1]) return timeList[len - 1]; // 二分查找核心 - let left = 0; let right = len - 1; + let left = 0; + let right = len - 1; while (left <= right) { const mid = Math.floor((left + right) / 2); const midTs = timeList[mid]; @@ -59,11 +60,9 @@ function initPlaybackWorker() { if (currentTime >= midTs && currentTime < nextTs) { return midTs; - } - else if (midTs < currentTime) { + } else if (midTs < currentTime) { left = mid + 1; - } - else { + } else { right = mid - 1; } } @@ -88,7 +87,7 @@ function initPlaybackWorker() { while (current.isBefore(end) || current.isSame(end)) { timeStamps.push(current.valueOf()); // 时间戳(毫秒) hsjmnQueryTimeList.push(accumulatedHours); // 累计小时数 - current = current.add(1, 'hour'); // 每6小时递增 + current = current.add(1, "hour"); // 每6小时递增 accumulatedHours += 1; // 累计小时数+6 } @@ -102,14 +101,14 @@ function initPlaybackWorker() { onmessage = function (event) { const wData = event.data; switch (wData.code) { - case 'init': + case "init": serverIp = wData.value.serverIp; token = wData.value.token; importScripts(`${wData.value.serverIp}/worker/fetchHttp.js`); importScripts(`${wData.value.serverIp}/worker/turf.min.js`); importScripts(`${wData.value.serverIp}/worker/dayjs.min.js`); break; - case 'clearCache': + case "clearCache": isAllowSymn = true; isAllowHsjmnDNFB = true; isAllowHsjmnJLFB = true; @@ -117,159 +116,168 @@ function initPlaybackWorker() { hsjmnResult1.clear(); hsjmnResult2.clear(); break; - case 'querySYMN': + case "querySYMN": // 初始化循环参数(每次启动前重置) if (isAllowSymn) { symnNum = 0; symmMaxNum = wData.data.globalAttr.time; lonWidth = wData.data.globalAttr.dxout; latWidth = wData.data.globalAttr.dyout; + latGroupData = wData.data.globalAttr.latData; + lonGroupData = wData.data.globalAttr.lonData; cycleBaseParams = wData.data; - + console.log(wData,"console.log(wData)") + symnResult.clear(); isCycleAborted = false; isFirstSuccessSent = false; // 重置第一次成功标记 timeList = []; lastTargetTs = null; for (let i = 0; i < symmMaxNum; i++) { - // 核心逻辑:累加 3 小时后,获取毫秒时间戳 + // 核心逻辑:累加 3 小时后,获取毫秒时间戳 const currentTimeStamp = dayjs(wData.data.timeRange.formatStartTime) - .add(i * 3, 'hour') // 每次加 3 小时 + .add(i * 3, "hour") // 每次加 3 小时 .valueOf(); // 转换为毫秒时间戳(等价于 .getTime()) timeList.push(currentTimeStamp); } - + getSYMNplaybackData(queryApi.symn); - } - else { + } else { postMessage({ - code: 'querySYMNFirstSuccess', // 第一次成功消息码 + code: "querySYMNFirstSuccess", // 第一次成功消息码 data: { index: symnNum, // 第一次成功的索引(0 开始) result: symnResult.get(timeList[0]), lonWidth, latWidth, - taskId: cycleBaseParams?.taskId || '', + taskId: cycleBaseParams?.taskId || "", + }, }); postMessage({ - code: 'querySYMNAllSuccess', // 明确“所有请求成功”消息码 + code: "querySYMNAllSuccess", // 明确“所有请求成功”消息码 }); } break; - case 'drawSYMN':{ - // 查找当前时间对应的目标时间戳 - const targetTs = findTargetTimestamp(wData.data); + case "drawSYMN": + { + // 查找当前时间对应的目标时间戳 + const targetTs = findTargetTimestamp(wData.data); - // 只有目标时间戳变化时,才更新数据(避免重复) - if (targetTs === lastTargetTs) { - // 时间戳未变,无需更新,直接跳过 - return; + // 只有目标时间戳变化时,才更新数据(避免重复) + if (targetTs === lastTargetTs) { + // 时间戳未变,无需更新,直接跳过 + return; + } + + // 时间戳变化:获取对应数据(存在才更新) + const targetData = symnResult.get(targetTs); + + lastTargetTs = targetTs; // 更新记录的时间戳 + + postMessage({ + code: "drawSYMN", // 第一次成功消息码 + data: targetData, + lonWidth, + latWidth, + }); } - - // 时间戳变化:获取对应数据(存在才更新) - const targetData = symnResult.get(targetTs); - - lastTargetTs = targetTs; // 更新记录的时间戳 - - postMessage({ - code: 'drawSYMN', // 第一次成功消息码 - data: targetData, - lonWidth, - latWidth, - }); - } break; // 新增:主动终止循环请求(外部触发) - case 'abortSYMN': + case "abortSYMN": isCycleAborted = true; // 标记终止 break; - case 'queryHSJMNType': + case "queryHSJMNType": postMessage({ - code: 'queryHSJMNType', // 第一次成功消息码 + code: "queryHSJMNType", // 第一次成功消息码 data: wData.data, }); break; - case 'queryHSJMNndfb': + case "queryHSJMNndfb": if (isAllowHsjmnDNFB) { cycleBaseParams = wData.data; - const res = generateTimeStampsAndHours(wData.data.timeRange.formatStartTime, wData.data.timeRange.formatEndTime); + const res = generateTimeStampsAndHours( + wData.data.timeRange.formatStartTime, + wData.data.timeRange.formatEndTime, + ); timeList = res.timeStamps; hsjmnQueryTimeList = res.hsjmnQueryTimeList; isCycleAborted = false; lastTargetTs = null; getHSJMNplaybackData1(queryApi.hsjmnNDFB); - } - else { + } else { postMessage({ - code: 'queryHSJMNFirstSuccess', + code: "queryHSJMNFirstSuccess", data: { result: hsjmnResult1.get(timeList[0]), // 首次成功的完整结果 }, }); // 发送接口1全部完成消息 postMessage({ - code: 'queryHSJMNAllSuccess', + code: "queryHSJMNAllSuccess", data: { successCount: hsjmnResult1.size, }, }); } break; - case 'queryHSJMNjlfb': + case "queryHSJMNjlfb": if (isAllowHsjmnJLFB) { cycleBaseParams = wData.data; - const res = generateTimeStampsAndHours(wData.data.timeRange.formatStartTime, wData.data.timeRange.formatEndTime); + const res = generateTimeStampsAndHours( + wData.data.timeRange.formatStartTime, + wData.data.timeRange.formatEndTime, + ); timeList = res.timeStamps; hsjmnQueryTimeList = res.hsjmnQueryTimeList; isCycleAborted = false; lastTargetTs = null; getHSJMNplaybackData2(queryApi.hsjmnJLFB); - } - else { + } else { postMessage({ - code: 'queryHSJMNFirstSuccess', + code: "queryHSJMNFirstSuccess", data: { result: hsjmnResult2.get(timeList[0]), // 首次成功的完整结果 }, }); // 发送接口1全部完成消息 postMessage({ - code: 'queryHSJMNAllSuccess', + code: "queryHSJMNAllSuccess", data: { successCount: hsjmnResult1.size, }, }); } break; - case 'drawHSJMN':{ - console.log('1111111111111111111111111111111'); + case "drawHSJMN": + { + console.log("1111111111111111111111111111111"); - // 查找当前时间对应的目标时间戳 - const targetTs = findTargetTimestamp(wData.data); + // 查找当前时间对应的目标时间戳 + const targetTs = findTargetTimestamp(wData.data); - // 只有目标时间戳变化时,才更新数据(避免重复) - if (targetTs === lastTargetTs) { - // 时间戳未变,无需更新,直接跳过 - return; + // 只有目标时间戳变化时,才更新数据(避免重复) + if (targetTs === lastTargetTs) { + // 时间戳未变,无需更新,直接跳过 + return; + } + + // 时间戳变化:获取对应数据(存在才更新) + const targetData1 = hsjmnResult1.get(targetTs); + const targetData2 = hsjmnResult2.get(targetTs); + + lastTargetTs = targetTs; // 更新记录的时间戳 + + postMessage({ + code: "drawHSJMN", // 第一次成功消息码 + data: { + hsjmnResult1Count: targetData1, + hsjmnResult2Count: targetData2, + }, + }); } - - // 时间戳变化:获取对应数据(存在才更新) - const targetData1 = hsjmnResult1.get(targetTs); - const targetData2 = hsjmnResult2.get(targetTs); - - lastTargetTs = targetTs; // 更新记录的时间戳 - - postMessage({ - code: 'drawHSJMN', // 第一次成功消息码 - data: { - hsjmnResult1Count: targetData1, - hsjmnResult2Count: targetData2, - }, - }); - } break; default: @@ -285,7 +293,7 @@ function initPlaybackWorker() { // 只有未终止且所有请求完成,才发送“所有成功”消息 if (!isCycleAborted) { postMessage({ - code: 'querySYMNAllSuccess', // 明确“所有请求成功”消息码 + code: "querySYMNAllSuccess", // 明确“所有请求成功”消息码 }); isAllowSymn = false; // 标记后续不再请求 } @@ -303,7 +311,10 @@ function initPlaybackWorker() { } fetchHttp(url, { params: param, token }, (res) => { - console.log(`第 ${symnNum + 1}/${symmMaxNum} 次请求结果`, res.success ? '成功' : '失败'); + console.log( + `第 ${symnNum + 1}/${symmMaxNum} 次请求结果`, + res.success ? "成功" : "失败", + ); if (res.success) { symnResult.set(timeList[symnNum], res.result); @@ -312,31 +323,35 @@ function initPlaybackWorker() { if (!isFirstSuccessSent) { isFirstSuccessSent = true; postMessage({ - code: 'querySYMNFirstSuccess', // 第一次成功消息码 + code: "querySYMNFirstSuccess", // 第一次成功消息码 data: { index: symnNum, // 第一次成功的索引(0 开始) result: res.result, lonWidth, latWidth, - taskId: cycleBaseParams?.taskId || '', + taskId: cycleBaseParams?.taskId || "", + latGroupData, + lonGroupData }, }); } symnNum++; getSYMNplaybackData(params); // 发起下一次请求 - } - else { + } else { // 发送“某一次失败”消息(仅发送当前失败,然后终止循环) isCycleAborted = true; - console.error(`❌ 第 ${symnNum + 1} 次请求失败:`, res.msg || '未知错误'); + console.error( + `❌ 第 ${symnNum + 1} 次请求失败:`, + res.msg || "未知错误", + ); postMessage({ - code: 'querySYMNOneFail', // 单次失败消息码 + code: "querySYMNOneFail", // 单次失败消息码 data: { index: symnNum, // 失败的索引 - msg: res.msg || '未知错误', + msg: res.msg || "未知错误", successCount: symnResult.size, // 失败前已成功的次数 - taskId: cycleBaseParams?.taskId || '', + taskId: cycleBaseParams?.taskId || "", }, }); } @@ -370,11 +385,11 @@ function initPlaybackWorker() { * 单个请求递归逻辑 */ function requestSingle() { - // 1. 终止条件:请求完成/被中断 + // 1. 终止条件:请求完成/被中断 if (currentIndex >= totalCount) { - // 发送接口1全部完成消息 + // 发送接口1全部完成消息 postMessage({ - code: 'queryHSJMNAllSuccess', + code: "queryHSJMNAllSuccess", data: { successCount: hsjmnResult1.size, }, @@ -395,30 +410,34 @@ function initPlaybackWorker() { }; // 3. 发送请求 - fetchHttp(requestUrl, { params: requestParam, token }, (res) => { - // 存储结果到Map(key: 时间戳,value: 接口返回的result) - hsjmnResult1.set(timeList[currentIndex], res.result); + fetchHttp( + requestUrl, + { params: requestParam, token }, + (res) => { + // 存储结果到Map(key: 时间戳,value: 接口返回的result) + hsjmnResult1.set(timeList[currentIndex], res.result); - // 首次请求成功:发送专属消息 - if (isFirstSend) { - postMessage({ - code: 'queryHSJMNFirstSuccess', - data: { - result: res.result, // 首次成功的完整结果 - }, - }); - isAllowHsjmnDNFB = false; - isFirstSend = false; // 重置首次标记,后续不再发送 - } + // 首次请求成功:发送专属消息 + if (isFirstSend) { + postMessage({ + code: "queryHSJMNFirstSuccess", + data: { + result: res.result, // 首次成功的完整结果 + }, + }); + isAllowHsjmnDNFB = false; + isFirstSend = false; // 重置首次标记,后续不再发送 + } - // 进入下一次请求 - currentIndex++; - requestSingle(); - }, - // 失败回调 - (error) => { - console.error(`❌ 接口1第 ${currentIndex + 1} 次请求失败:`, error); - }); + // 进入下一次请求 + currentIndex++; + requestSingle(); + }, + // 失败回调 + (error) => { + console.error(`❌ 接口1第 ${currentIndex + 1} 次请求失败:`, error); + }, + ); } // 启动接口1的循环请求 requestSingle(); @@ -440,11 +459,11 @@ function initPlaybackWorker() { * 单个请求递归逻辑 */ function requestSingle() { - // 1. 终止条件:请求完成/被中断 + // 1. 终止条件:请求完成/被中断 if (currentIndex >= totalCount) { - // 发送接口1全部完成消息 + // 发送接口1全部完成消息 postMessage({ - code: 'queryHSJMNAllSuccess', + code: "queryHSJMNAllSuccess", data: { successCount: hsjmnResult1.size, }, @@ -465,30 +484,34 @@ function initPlaybackWorker() { }; // 3. 发送请求 - fetchHttp(requestUrl, { params: requestParam, token }, (res) => { - // 存储结果到Map(key: 时间戳,value: 接口返回的result) - hsjmnResult2.set(timeList[currentIndex], res.result); + fetchHttp( + requestUrl, + { params: requestParam, token }, + (res) => { + // 存储结果到Map(key: 时间戳,value: 接口返回的result) + hsjmnResult2.set(timeList[currentIndex], res.result); - // 首次请求成功:发送专属消息 - if (isFirstSend) { - postMessage({ - code: 'queryHSJMNFirstSuccess', - data: { - result: res.result, // 首次成功的完整结果 - }, - }); - isAllowHsjmnJLFB = false; - isFirstSend = false; // 重置首次标记,后续不再发送 - } + // 首次请求成功:发送专属消息 + if (isFirstSend) { + postMessage({ + code: "queryHSJMNFirstSuccess", + data: { + result: res.result, // 首次成功的完整结果 + }, + }); + isAllowHsjmnJLFB = false; + isFirstSend = false; // 重置首次标记,后续不再发送 + } - // 进入下一次请求 - currentIndex++; - requestSingle(); - }, - // 失败回调 - (error) => { - console.error(`❌ 接口1第 ${currentIndex + 1} 次请求失败:`, error); - }); + // 进入下一次请求 + currentIndex++; + requestSingle(); + }, + // 失败回调 + (error) => { + console.error(`❌ 接口1第 ${currentIndex + 1} 次请求失败:`, error); + }, + ); } // 启动接口1的循环请求 requestSingle(); diff --git a/src/gis/worker/playbackWorkerMa.ts b/src/gis/worker/playbackWorkerMa.ts index 288d2dd6..9e46296f 100644 --- a/src/gis/worker/playbackWorkerMa.ts +++ b/src/gis/worker/playbackWorkerMa.ts @@ -1,14 +1,23 @@ -import type { WorkerFunc } from './worketBlob'; -import dayjs from 'dayjs'; -import { serverIp } from '@/gis/common/config'; -import EventBus from '@/gis/common/eventBus/eventBus.ts'; -import { fitViewByExtent } from '@/gis/ol/index.ts'; -import { setShouldAnimate } from '@/gis/ol/olClock'; -import { addCustomGridLayer } from '@/gis/ol/olGirdHeatmap'; -import { addHeatmapLayer } from '@/gis/ol/olHeatmap'; +import type { WorkerFunc } from "./worketBlob"; +import dayjs from "dayjs"; +import { serverIp } from "@/gis/common/config"; +import EventBus from "@/gis/common/eventBus/eventBus.ts"; +import { fitViewByExtent } from "@/gis/ol/index.ts"; +import { setShouldAnimate } from "@/gis/ol/olClock"; +import { addCustomGridLayer } from "@/gis/ol/olGirdHeatmap"; +import { addHeatmapLayer } from "@/gis/ol/olHeatmap"; // import { addIsolineLayer } from '@/gis/ol/olIsoLine'; -import playbackWorker from './playbackWorker'; -import { getWorketImportUrl } from './worketBlob'; // 导入 WorkerFunc 类型 +import playbackWorker from "./playbackWorker"; +import { getWorketImportUrl } from "./worketBlob"; // 导入 WorkerFunc 类型 +import type { FlexpartColorScale } from "@/gis/ol/flexpartContour/flexpartContourTypes"; +import type { + FlexpartGridAxes, + FlexpartGridPoint, +} from "@/gis/ol/flexpartContour/flexpartContourTypes"; +import { + renderBackwardData, +} from '@/gis/ol/flexpartContour/flexpartContourExample'; + // 定义 Worker 消息数据的类型接口 interface WorkerMessage { code: string; @@ -27,22 +36,27 @@ type PostMessageData = Record; let playbackWorkerObj: Worker | null = null; export const playback_EventBusCallback = new EventBus(); -let hsjmnType = '浓度分布'; +let hsjmnType = "浓度分布"; + +let axes: FlexpartGridAxes = {}; +let colorScale: FlexpartColorScale = {}; /** * 计算经纬度点集合的 extent(地理范围) * @param points 经纬度点数组 [{ lon: 经度, lat: 纬度, ... }] * @returns extent 数组 [minLon, minLat, maxLon, maxLat],若无有效点返回 null */ -function calculatePointsExtent(points: Array<{ lon: number; lat: number }>): number[] | null { +function calculatePointsExtent( + points: Array<{ lon: number; lat: number }>, +): number[] | null { if (!points || points.length === 0) { - console.warn('无有效经纬度点,无法计算 extent'); + console.warn("无有效经纬度点,无法计算 extent"); return null; } // 提取所有经度和纬度 - const lons = points.map(p => p.lon); - const lats = points.map(p => p.lat); + const lons = points.map((p) => p.lon); + const lats = points.map((p) => p.lat); // 计算最小/最大经纬度(添加 0.01 缓冲,避免点贴边) const minLon = Math.min(...lons) - 0.01; @@ -58,16 +72,16 @@ function calculatePointsExtent(points: Array<{ lon: number; lat: number }>): num */ export function initPlaybackWorkerMa(): void { // 校验 playbackWorker 是符合要求的 Worker 函数(类型约束) - if (typeof playbackWorker !== 'function' || !playbackWorker.name) { - throw new Error('playbackWorker 必须是具名函数,且无外部依赖'); + if (typeof playbackWorker !== "function" || !playbackWorker.name) { + throw new Error("playbackWorker 必须是具名函数,且无外部依赖"); } // 创建 worket 对象(getWorketImportUrl 已约束入参为 WorkerFunc) const workerUrl = getWorketImportUrl(playbackWorker as WorkerFunc); playbackWorkerObj = new Worker(workerUrl); - const token = localStorage.getItem('token'); + const token = localStorage.getItem("token"); playbackWorkerObj.postMessage({ - code: 'init', + code: "init", value: { serverIp, token, @@ -78,173 +92,129 @@ export function initPlaybackWorkerMa(): void { playbackWorkerObj.onmessage = function (event: MessageEvent) { const weaData = event.data; switch (weaData.code) { - case 'querySYMNFirstSuccess':{ // 输运模拟第一次接口请求完成,开始绘制自定义热力图 - // 定义最大值 - let maxValue = 0; - let minValue = 0; - // 处理数据 - const gridData = weaData.data.result.map((item: any) => { - if (item.value > maxValue) { - maxValue = item.value; + case "querySYMNFirstSuccess": + { + // 输运模拟第一次接口请求完成,开始绘制自定义热力图 + // 定义最大值 + let maxValue = 0; + let minValue = 0; + // 处理数据 + const gridData = weaData.data.result.map((item: any) => { + if (item.value > maxValue) { + maxValue = item.value; + } + if (item.value !== "-Infinity" && item.value < minValue) { + minValue = item.value; + } + return { + lon: item.lon, + lat: item.lat, + value: item.value, + width: weaData.data.lonWidth, + height: weaData.data.latWidth, + }; + }); + + + let maxColorSymn = Number(sessionStorage.getItem("symnMaxColor")); + let colorArr = []; + for (let i = 0; i < 11; i++) { + let str = "1e"; + str += maxColorSymn - i; + colorArr.unshift(Number(str)); } - if (item.value !== '-Infinity' && item.value < minValue) { - minValue = item.value; - } - return { - lon: item.lon, - lat: item.lat, - value: item.value, - width: weaData.data.lonWidth, - height: weaData.data.latWidth, + + colorScale = { + colorMin: colorArr[0], + colorMax: colorArr[10], + levels: colorArr, }; - }); - // 加载自定义热力图 - addCustomGridLayer(gridData, maxValue, minValue); - - // 开始播放 - setShouldAnimate(true); - - // 显示图例 - // playback_EventBusCallback.publish('legendUiData', { - // key: 'showLengend', - // value: true, - // }); - // 更改图例最大值 - // playback_EventBusCallback.publish('symnLegendUpdateMax', { - // maxValue, - // minValue, - // }); - } - break; - case 'drawSYMN':{ // 绘制输运模拟自定义热力图 - // 定义最大值 - let maxValue = 0; - let minValue = 0; - - // 加工数据 - const gridData = weaData.data.map((item: any) => { - if (item.value > maxValue) { - maxValue = item.value; + axes = { + longitudes:weaData.data.lonGroupData, + latitudes:weaData.data.latGroupData, } - if (item.value !== '-Infinity' && item.value < minValue) { - minValue = item.value; - } - return { - lon: item.lon, - lat: item.lat, - value: item.value, - width: weaData.lonWidth, - height: weaData.latWidth, - }; - }); - // 加载自定义热力图 - addCustomGridLayer(gridData, maxValue, minValue); - // 更改最大值 - // playback_EventBusCallback.publish('symnLegendUpdateMax', { - // maxValue, - // minValue, - // }); - } + renderBackwardData(gridData,axes,colorScale); + // 加载自定义热力图 + // addCustomGridLayer(gridData, maxValue, minValue); + + // 开始播放 + setShouldAnimate(true); + + // 显示图例 + // playback_EventBusCallback.publish('legendUiData', { + // key: 'showLengend', + // value: true, + // }); + // 更改图例最大值 + // playback_EventBusCallback.publish('symnLegendUpdateMax', { + // maxValue, + // minValue, + // }); + } break; - case 'querySYMNOneFail': // 输运模拟接口请求失败 + case "drawSYMN": + { + // 绘制输运模拟自定义热力图 + // 定义最大值 + let maxValue = 0; + let minValue = 0; + + // 加工数据 + const gridData = weaData.data.map((item: any) => { + if (item.value > maxValue) { + maxValue = item.value; + } + if (item.value !== "-Infinity" && item.value < minValue) { + minValue = item.value; + } + return { + lon: item.lon, + lat: item.lat, + value: item.value, + width: weaData.lonWidth, + height: weaData.latWidth, + }; + }); + + // 加载自定义热力图 + // addCustomGridLayer(gridData, maxValue, minValue); + renderBackwardData(gridData,axes,colorScale); + // 更改最大值 + // playback_EventBusCallback.publish('symnLegendUpdateMax', { + // maxValue, + // minValue, + // }); + } + break; + case "querySYMNOneFail": // 输运模拟接口请求失败 // 处理失败场景(如提示用户、重试循环) - console.error('⚠️ 请求失败', weaData.data); + console.error("⚠️ 请求失败", weaData.data); break; - case 'querySYMNAllSuccess': // 输运模拟所有接口请求完成 + case "querySYMNAllSuccess": // 输运模拟所有接口请求完成 // 解除进度条禁用 - playback_EventBusCallback.publish('timeSliderUiData', { - key: 'isDisabledSlider', + playback_EventBusCallback.publish("timeSliderUiData", { + key: "isDisabledSlider", value: false, }); break; - case 'queryHSJMNType': + case "queryHSJMNType": hsjmnType = weaData.data; break; - case 'queryHSJMNFirstSuccess': { // 核事件模拟第一次请求完成,开始绘制 - // 显示图例 - playback_EventBusCallback.publish('legendUiData', { - key: 'showLengend', - value: true, - }); - - let data = null; - if (hsjmnType === '浓度分布') { - data = weaData.data.result; - // 加工数据 - const gridData = data.dataList.map((item: any) => { - return { - lon: item[0], - lat: item[1], - value: item[2] === 0 ? 0.08 : item[2], - }; + case "queryHSJMNFirstSuccess": + { + // 核事件模拟第一次请求完成,开始绘制 + // 显示图例 + playback_EventBusCallback.publish("legendUiData", { + key: "showLengend", + value: true, }); - const extent = calculatePointsExtent(gridData); - if (extent) { - // 定位 - fitViewByExtent(extent); - } - - // 添加热力图 - addHeatmapLayer(gridData, data.max === 0 ? 1 : data.max); - // 动态改变图例最大值 - playback_EventBusCallback.publish('symnLegendUpdateMax', { - maxValue: data.max, - minValue: data.min, - }); - } - else { - data = weaData.data.result; - - // 加工数据 - const fitData = data.dataList.map((item: any) => { - return { - lon: item[0], - lat: item[1], - }; - }); - - const extent = calculatePointsExtent(fitData); - if (extent) { - // 定位 - fitViewByExtent(extent); - } - - // 加工数据 - const gridData = data.dataList.filter(item => item[2] >= 1.5).map((item: any) => { - return { - lon: item[0], - lat: item[1], - value: item[2], - }; - }); - - // 添加热力图 - addHeatmapLayer(gridData, data.max === 0 ? 1 : data.max); - // 渲染等值线 - // addIsolineLayer(gridData); // 可自定义等值线间隔 - // 动态改变图例最大值 - playback_EventBusCallback.publish('symnLegendUpdateMax', { - maxValue: data.max, - minValue: data.min, - }); - } - - // 开始播放 - setShouldAnimate(true); - } - break; - case 'drawHSJMN':{ // 绘制核事件模拟自定义热力图 - // 加工数据 - if (!weaData.data) - return; - - let data = null; - if (hsjmnType === '浓度分布') { - data = weaData.data.hsjmnResult1Count; - if (data) { + let data = null; + if (hsjmnType === "浓度分布") { + data = weaData.data.result; + // 加工数据 const gridData = data.dataList.map((item: any) => { return { lon: item[0], @@ -253,43 +223,118 @@ export function initPlaybackWorkerMa(): void { }; }); + const extent = calculatePointsExtent(gridData); + if (extent) { + // 定位 + fitViewByExtent(extent); + } + // 添加热力图 addHeatmapLayer(gridData, data.max === 0 ? 1 : data.max); // 动态改变图例最大值 - playback_EventBusCallback.publish('symnLegendUpdateMax', { + playback_EventBusCallback.publish("symnLegendUpdateMax", { maxValue: data.max, minValue: data.min, }); - } - } - else { - data = weaData.data.hsjmnResult2Count; - if (data) { - const gridData = data.dataList.filter(item => item[2] >= 1.5).map((item: any) => { + } else { + data = weaData.data.result; + + // 加工数据 + const fitData = data.dataList.map((item: any) => { return { lon: item[0], lat: item[1], - value: item[2], }; }); + const extent = calculatePointsExtent(fitData); + if (extent) { + // 定位 + fitViewByExtent(extent); + } + + // 加工数据 + const gridData = data.dataList + .filter((item) => item[2] >= 1.5) + .map((item: any) => { + return { + lon: item[0], + lat: item[1], + value: item[2], + }; + }); + // 添加热力图 addHeatmapLayer(gridData, data.max === 0 ? 1 : data.max); // 渲染等值线 // addIsolineLayer(gridData); // 可自定义等值线间隔 // 动态改变图例最大值 - playback_EventBusCallback.publish('symnLegendUpdateMax', { + playback_EventBusCallback.publish("symnLegendUpdateMax", { maxValue: data.max, minValue: data.min, }); } + + // 开始播放 + setShouldAnimate(true); } - } break; - case 'queryHSJMNAllSuccess': // 核事件模拟所有接口请求完成 + case "drawHSJMN": + { + // 绘制核事件模拟自定义热力图 + // 加工数据 + if (!weaData.data) return; + + let data = null; + if (hsjmnType === "浓度分布") { + data = weaData.data.hsjmnResult1Count; + if (data) { + const gridData = data.dataList.map((item: any) => { + return { + lon: item[0], + lat: item[1], + value: item[2] === 0 ? 0.08 : item[2], + }; + }); + + // 添加热力图 + addHeatmapLayer(gridData, data.max === 0 ? 1 : data.max); + // 动态改变图例最大值 + playback_EventBusCallback.publish("symnLegendUpdateMax", { + maxValue: data.max, + minValue: data.min, + }); + } + } else { + data = weaData.data.hsjmnResult2Count; + if (data) { + const gridData = data.dataList + .filter((item) => item[2] >= 1.5) + .map((item: any) => { + return { + lon: item[0], + lat: item[1], + value: item[2], + }; + }); + + // 添加热力图 + addHeatmapLayer(gridData, data.max === 0 ? 1 : data.max); + // 渲染等值线 + // addIsolineLayer(gridData); // 可自定义等值线间隔 + // 动态改变图例最大值 + playback_EventBusCallback.publish("symnLegendUpdateMax", { + maxValue: data.max, + minValue: data.min, + }); + } + } + } + break; + case "queryHSJMNAllSuccess": // 核事件模拟所有接口请求完成 // 解除进度条禁用 - playback_EventBusCallback.publish('timeSliderUiData', { - key: 'isDisabledSlider', + playback_EventBusCallback.publish("timeSliderUiData", { + key: "isDisabledSlider", value: false, }); break; @@ -298,7 +343,7 @@ export function initPlaybackWorkerMa(): void { // 监听 Worker 错误(可选:增强错误处理) playbackWorkerObj.onerror = function (error) { - console.error('Worker 执行错误:', error.message, '行号:', error.lineno); + console.error("Worker 执行错误:", error.message, "行号:", error.lineno); // 可根据需求添加错误上报或重试逻辑 }; } @@ -323,9 +368,8 @@ export function sendPlaybackPostMassage( // 确保 playbackWorkerObj 已初始化(TS 类型守卫) if (playbackWorkerObj) { playbackWorkerObj.postMessage(massage); - } - else { - throw new Error('Web Worker 初始化失败,无法发送消息'); + } else { + throw new Error("Web Worker 初始化失败,无法发送消息"); } } diff --git a/src/layout/Container/newSymnLegend.vue b/src/layout/Container/newSymnLegend.vue index d4ed0093..3cf44532 100644 --- a/src/layout/Container/newSymnLegend.vue +++ b/src/layout/Container/newSymnLegend.vue @@ -36,12 +36,17 @@ const props = defineProps({ }, }); +const unitValue = ref(null); + watch( () => props.startExp, // 函数写法,监听props属性(推荐) (newVal, oldVal) => { console.log("父组件传过来的值变化了", newVal, oldVal); // 在这里写你的业务:生成10个对数刻度、重新渲染图表等 handleGenTicks(newVal); + sessionStorage.setItem("symnMaxColor",newVal); + + unitValue.value = JSON.parse(sessionStorage.getItem("ssmnDataItem")).taskMode==-1?"m-3":"mBq/m3" }, { immediate: true } // immediate:true 组件初始化时立刻执行一次,非常常用! ); @@ -55,7 +60,7 @@ function handleGenTicks(maxVal) { label: maxVal - index, }; - colorList.value.push(item1); + colorList.value.unshift(item1); }); } @@ -72,6 +77,7 @@ function handleGenTicks(maxVal) { > +
10{{ item.label }}(); @@ -1020,6 +1020,14 @@ function getStations() { }); } +// 停止任务 +function stopTaskRw(taskId){ + console.log(taskId,"222") + stopRw({taskId},(res)=>{ + + }) +} + getList(); // 查询所有核设施 getNuclearfacility(); @@ -1046,7 +1054,7 @@ getStations(); - + @@ -1277,6 +1285,7 @@ getStations(); class="custom-select" > + @@ -1346,7 +1355,7 @@ getStations(); @@ -1514,6 +1523,15 @@ getStations(); > {{ t("operationLog") }} + + + 停止任务 + @@ -1593,6 +1611,7 @@ getStations(); @change="clearChild" > + diff --git a/src/utils/axios/fskjk/index.ts b/src/utils/axios/fskjk/index.ts index b4e316b1..aef6eecb 100644 --- a/src/utils/axios/fskjk/index.ts +++ b/src/utils/axios/fskjk/index.ts @@ -113,4 +113,24 @@ export async function getRwNcMaxValue( if (response.success && response.code === 200) { callback?.(response); } +} + +// 停止任务 +export async function stopRw( + params: any, + callback?: (data: any) => void, +): Promise { + const response = await request({ + url: `${serverIp}${baseURL}/transportTask/stopTask`, + method: "PUT", + params, + }); + + if (response.success && response.code === 200) { + ElMessage({ + type: "success", + message: "停止成功", + }); + callback?.(response); + } } \ No newline at end of file