输运模拟修改热力图

This commit is contained in:
冯少康 2026-08-11 18:24:37 +08:00
parent 3aff577410
commit cbfdbbc672
12 changed files with 1384 additions and 343 deletions

View File

@ -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<ContourWorkerRequest>) => 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),
});
}
};

View File

@ -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<number, number>] {
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<number, number>();
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];
}

View File

@ -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<FlexpartContourLayerResult> {
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<void> {
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',
},
);
}

View File

@ -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<ImageCanvas>;
private readonly worker: Worker;
private readonly pending = new Map<number, PendingRequest>();
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<ContourWorkerResponse>,
) => {
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<FlexpartContourLayerResult> {
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<ImageCanvas> {
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<SerializedContour[]> {
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<FlexpartContourLayerResult> {
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;
}

View File

@ -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<number> | 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;

View File

@ -17,6 +17,8 @@ import { windDataHandle } from './tool/handle/windDataHandle';
import { addHeadMap, removeHeadMap } from './tool/heatMap'; import { addHeadMap, removeHeadMap } from './tool/heatMap';
import { addWeathLayer, removeWeathLayer, setWeathLayerVisity } from './tool/weath'; import { addWeathLayer, removeWeathLayer, setWeathLayerVisity } from './tool/weath';
import { addWindLayer, removeWindLayer, setWindLayerVisity } from './tool/wind'; import { addWindLayer, removeWindLayer, setWindLayerVisity } from './tool/wind';
import { clearFlexpartContourLayer } from './flexpartContour/flexpartContourLayer';
// 当前模块信息 // 当前模块信息
let activeMoudleInf: any = { let activeMoudleInf: any = {
@ -128,6 +130,10 @@ const palyDateEvent: any = {
setShouldAnimate(false); setShouldAnimate(false);
// 清理自定义热力图 // 清理自定义热力图
clearGridFeatures(); clearGridFeatures();
// 清空画面,图层保留
clearFlexpartContourLayer();
// 清理热力图 // 清理热力图
clearHeatmapFeatures(); clearHeatmapFeatures();
// 移除标记点 // 移除标记点

View File

@ -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({ const map = new Map({

View File

@ -1,22 +1,22 @@
function initPlaybackWorker() { function initPlaybackWorker() {
const queryApi = { const queryApi = {
symn: { symn: {
url: '/transportResult/getDiffusionData', url: "/transportResult/getDiffusionData",
method: 'GET', method: "GET",
}, },
hsjmnNDFB: { hsjmnNDFB: {
url: '/diffusionData/getDiffusionResult', url: "/diffusionData/getDiffusionResult",
method: 'GET', method: "GET",
}, },
hsjmnJLFB: { hsjmnJLFB: {
url: '/doseData/getDoseResult', url: "/doseData/getDoseResult",
method: 'GET', method: "GET",
}, },
}; };
let serverIp = ''; let serverIp = "";
let token = ''; let token = "";
const baseURL = '/api'; const baseURL = "/api";
let symnNum = 0; // 当前请求次数(从 0 开始) let symnNum = 0; // 当前请求次数(从 0 开始)
let symmMaxNum = 30; // 总循环次数 let symmMaxNum = 30; // 总循环次数
const symnResult = new Map(); // 存储所有请求结果 const symnResult = new Map(); // 存储所有请求结果
@ -34,6 +34,9 @@ function initPlaybackWorker() {
let lastTargetTs = null; // 关键:记录上一次匹配的时间戳(初始为 null let lastTargetTs = null; // 关键:记录上一次匹配的时间戳(初始为 null
let lonWidth = null; let lonWidth = null;
let latWidth = null; let latWidth = null;
let latGroupData = null;
let lonGroupData = null;
/** /**
* *
@ -41,17 +44,15 @@ function initPlaybackWorker() {
* @returns null * @returns null
*/ */
function findTargetTimestamp(currentTime) { function findTargetTimestamp(currentTime) {
if (!timeList || timeList.length === 0) if (!timeList || timeList.length === 0) return null;
return null;
const len = timeList.length; const len = timeList.length;
if (currentTime <= timeList[0]) if (currentTime <= timeList[0]) return timeList[0];
return timeList[0]; if (currentTime >= timeList[len - 1]) return timeList[len - 1];
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) { while (left <= right) {
const mid = Math.floor((left + right) / 2); const mid = Math.floor((left + right) / 2);
const midTs = timeList[mid]; const midTs = timeList[mid];
@ -59,11 +60,9 @@ function initPlaybackWorker() {
if (currentTime >= midTs && currentTime < nextTs) { if (currentTime >= midTs && currentTime < nextTs) {
return midTs; return midTs;
} } else if (midTs < currentTime) {
else if (midTs < currentTime) {
left = mid + 1; left = mid + 1;
} } else {
else {
right = mid - 1; right = mid - 1;
} }
} }
@ -88,7 +87,7 @@ function initPlaybackWorker() {
while (current.isBefore(end) || current.isSame(end)) { while (current.isBefore(end) || current.isSame(end)) {
timeStamps.push(current.valueOf()); // 时间戳(毫秒) timeStamps.push(current.valueOf()); // 时间戳(毫秒)
hsjmnQueryTimeList.push(accumulatedHours); // 累计小时数 hsjmnQueryTimeList.push(accumulatedHours); // 累计小时数
current = current.add(1, 'hour'); // 每6小时递增 current = current.add(1, "hour"); // 每6小时递增
accumulatedHours += 1; // 累计小时数+6 accumulatedHours += 1; // 累计小时数+6
} }
@ -102,14 +101,14 @@ function initPlaybackWorker() {
onmessage = function (event) { onmessage = function (event) {
const wData = event.data; const wData = event.data;
switch (wData.code) { switch (wData.code) {
case 'init': case "init":
serverIp = wData.value.serverIp; serverIp = wData.value.serverIp;
token = wData.value.token; token = wData.value.token;
importScripts(`${wData.value.serverIp}/worker/fetchHttp.js`); importScripts(`${wData.value.serverIp}/worker/fetchHttp.js`);
importScripts(`${wData.value.serverIp}/worker/turf.min.js`); importScripts(`${wData.value.serverIp}/worker/turf.min.js`);
importScripts(`${wData.value.serverIp}/worker/dayjs.min.js`); importScripts(`${wData.value.serverIp}/worker/dayjs.min.js`);
break; break;
case 'clearCache': case "clearCache":
isAllowSymn = true; isAllowSymn = true;
isAllowHsjmnDNFB = true; isAllowHsjmnDNFB = true;
isAllowHsjmnJLFB = true; isAllowHsjmnJLFB = true;
@ -117,14 +116,17 @@ function initPlaybackWorker() {
hsjmnResult1.clear(); hsjmnResult1.clear();
hsjmnResult2.clear(); hsjmnResult2.clear();
break; break;
case 'querySYMN': case "querySYMN":
// 初始化循环参数(每次启动前重置) // 初始化循环参数(每次启动前重置)
if (isAllowSymn) { if (isAllowSymn) {
symnNum = 0; symnNum = 0;
symmMaxNum = wData.data.globalAttr.time; symmMaxNum = wData.data.globalAttr.time;
lonWidth = wData.data.globalAttr.dxout; lonWidth = wData.data.globalAttr.dxout;
latWidth = wData.data.globalAttr.dyout; latWidth = wData.data.globalAttr.dyout;
latGroupData = wData.data.globalAttr.latData;
lonGroupData = wData.data.globalAttr.lonData;
cycleBaseParams = wData.data; cycleBaseParams = wData.data;
console.log(wData,"console.log(wData)")
symnResult.clear(); symnResult.clear();
isCycleAborted = false; isCycleAborted = false;
@ -132,144 +134,150 @@ function initPlaybackWorker() {
timeList = []; timeList = [];
lastTargetTs = null; lastTargetTs = null;
for (let i = 0; i < symmMaxNum; i++) { for (let i = 0; i < symmMaxNum; i++) {
// 核心逻辑:累加 3 小时后,获取毫秒时间戳 // 核心逻辑:累加 3 小时后,获取毫秒时间戳
const currentTimeStamp = dayjs(wData.data.timeRange.formatStartTime) const currentTimeStamp = dayjs(wData.data.timeRange.formatStartTime)
.add(i * 3, 'hour') // 每次加 3 小时 .add(i * 3, "hour") // 每次加 3 小时
.valueOf(); // 转换为毫秒时间戳(等价于 .getTime() .valueOf(); // 转换为毫秒时间戳(等价于 .getTime()
timeList.push(currentTimeStamp); timeList.push(currentTimeStamp);
} }
getSYMNplaybackData(queryApi.symn); getSYMNplaybackData(queryApi.symn);
} } else {
else {
postMessage({ postMessage({
code: 'querySYMNFirstSuccess', // 第一次成功消息码 code: "querySYMNFirstSuccess", // 第一次成功消息码
data: { data: {
index: symnNum, // 第一次成功的索引0 开始) index: symnNum, // 第一次成功的索引0 开始)
result: symnResult.get(timeList[0]), result: symnResult.get(timeList[0]),
lonWidth, lonWidth,
latWidth, latWidth,
taskId: cycleBaseParams?.taskId || '', taskId: cycleBaseParams?.taskId || "",
}, },
}); });
postMessage({ postMessage({
code: 'querySYMNAllSuccess', // 明确“所有请求成功”消息码 code: "querySYMNAllSuccess", // 明确“所有请求成功”消息码
}); });
} }
break; break;
case 'drawSYMN':{ case "drawSYMN":
// 查找当前时间对应的目标时间戳 {
const targetTs = findTargetTimestamp(wData.data); // 查找当前时间对应的目标时间戳
const targetTs = findTargetTimestamp(wData.data);
// 只有目标时间戳变化时,才更新数据(避免重复) // 只有目标时间戳变化时,才更新数据(避免重复)
if (targetTs === lastTargetTs) { if (targetTs === lastTargetTs) {
// 时间戳未变,无需更新,直接跳过 // 时间戳未变,无需更新,直接跳过
return; 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; break;
// 新增:主动终止循环请求(外部触发) // 新增:主动终止循环请求(外部触发)
case 'abortSYMN': case "abortSYMN":
isCycleAborted = true; // 标记终止 isCycleAborted = true; // 标记终止
break; break;
case 'queryHSJMNType': case "queryHSJMNType":
postMessage({ postMessage({
code: 'queryHSJMNType', // 第一次成功消息码 code: "queryHSJMNType", // 第一次成功消息码
data: wData.data, data: wData.data,
}); });
break; break;
case 'queryHSJMNndfb': case "queryHSJMNndfb":
if (isAllowHsjmnDNFB) { if (isAllowHsjmnDNFB) {
cycleBaseParams = wData.data; 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; timeList = res.timeStamps;
hsjmnQueryTimeList = res.hsjmnQueryTimeList; hsjmnQueryTimeList = res.hsjmnQueryTimeList;
isCycleAborted = false; isCycleAborted = false;
lastTargetTs = null; lastTargetTs = null;
getHSJMNplaybackData1(queryApi.hsjmnNDFB); getHSJMNplaybackData1(queryApi.hsjmnNDFB);
} } else {
else {
postMessage({ postMessage({
code: 'queryHSJMNFirstSuccess', code: "queryHSJMNFirstSuccess",
data: { data: {
result: hsjmnResult1.get(timeList[0]), // 首次成功的完整结果 result: hsjmnResult1.get(timeList[0]), // 首次成功的完整结果
}, },
}); });
// 发送接口1全部完成消息 // 发送接口1全部完成消息
postMessage({ postMessage({
code: 'queryHSJMNAllSuccess', code: "queryHSJMNAllSuccess",
data: { data: {
successCount: hsjmnResult1.size, successCount: hsjmnResult1.size,
}, },
}); });
} }
break; break;
case 'queryHSJMNjlfb': case "queryHSJMNjlfb":
if (isAllowHsjmnJLFB) { if (isAllowHsjmnJLFB) {
cycleBaseParams = wData.data; 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; timeList = res.timeStamps;
hsjmnQueryTimeList = res.hsjmnQueryTimeList; hsjmnQueryTimeList = res.hsjmnQueryTimeList;
isCycleAborted = false; isCycleAborted = false;
lastTargetTs = null; lastTargetTs = null;
getHSJMNplaybackData2(queryApi.hsjmnJLFB); getHSJMNplaybackData2(queryApi.hsjmnJLFB);
} } else {
else {
postMessage({ postMessage({
code: 'queryHSJMNFirstSuccess', code: "queryHSJMNFirstSuccess",
data: { data: {
result: hsjmnResult2.get(timeList[0]), // 首次成功的完整结果 result: hsjmnResult2.get(timeList[0]), // 首次成功的完整结果
}, },
}); });
// 发送接口1全部完成消息 // 发送接口1全部完成消息
postMessage({ postMessage({
code: 'queryHSJMNAllSuccess', code: "queryHSJMNAllSuccess",
data: { data: {
successCount: hsjmnResult1.size, successCount: hsjmnResult1.size,
}, },
}); });
} }
break; break;
case 'drawHSJMN':{ case "drawHSJMN":
console.log('1111111111111111111111111111111'); {
console.log("1111111111111111111111111111111");
// 查找当前时间对应的目标时间戳 // 查找当前时间对应的目标时间戳
const targetTs = findTargetTimestamp(wData.data); const targetTs = findTargetTimestamp(wData.data);
// 只有目标时间戳变化时,才更新数据(避免重复) // 只有目标时间戳变化时,才更新数据(避免重复)
if (targetTs === lastTargetTs) { if (targetTs === lastTargetTs) {
// 时间戳未变,无需更新,直接跳过 // 时间戳未变,无需更新,直接跳过
return; 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; break;
default: default:
@ -285,7 +293,7 @@ function initPlaybackWorker() {
// 只有未终止且所有请求完成,才发送“所有成功”消息 // 只有未终止且所有请求完成,才发送“所有成功”消息
if (!isCycleAborted) { if (!isCycleAborted) {
postMessage({ postMessage({
code: 'querySYMNAllSuccess', // 明确“所有请求成功”消息码 code: "querySYMNAllSuccess", // 明确“所有请求成功”消息码
}); });
isAllowSymn = false; // 标记后续不再请求 isAllowSymn = false; // 标记后续不再请求
} }
@ -303,7 +311,10 @@ function initPlaybackWorker() {
} }
fetchHttp(url, { params: param, token }, (res) => { fetchHttp(url, { params: param, token }, (res) => {
console.log(`${symnNum + 1}/${symmMaxNum} 次请求结果`, res.success ? '成功' : '失败'); console.log(
`${symnNum + 1}/${symmMaxNum} 次请求结果`,
res.success ? "成功" : "失败",
);
if (res.success) { if (res.success) {
symnResult.set(timeList[symnNum], res.result); symnResult.set(timeList[symnNum], res.result);
@ -312,31 +323,35 @@ function initPlaybackWorker() {
if (!isFirstSuccessSent) { if (!isFirstSuccessSent) {
isFirstSuccessSent = true; isFirstSuccessSent = true;
postMessage({ postMessage({
code: 'querySYMNFirstSuccess', // 第一次成功消息码 code: "querySYMNFirstSuccess", // 第一次成功消息码
data: { data: {
index: symnNum, // 第一次成功的索引0 开始) index: symnNum, // 第一次成功的索引0 开始)
result: res.result, result: res.result,
lonWidth, lonWidth,
latWidth, latWidth,
taskId: cycleBaseParams?.taskId || '', taskId: cycleBaseParams?.taskId || "",
latGroupData,
lonGroupData
}, },
}); });
} }
symnNum++; symnNum++;
getSYMNplaybackData(params); // 发起下一次请求 getSYMNplaybackData(params); // 发起下一次请求
} } else {
else {
// 发送“某一次失败”消息(仅发送当前失败,然后终止循环) // 发送“某一次失败”消息(仅发送当前失败,然后终止循环)
isCycleAborted = true; isCycleAborted = true;
console.error(`❌ 第 ${symnNum + 1} 次请求失败:`, res.msg || '未知错误'); console.error(
`❌ 第 ${symnNum + 1} 次请求失败:`,
res.msg || "未知错误",
);
postMessage({ postMessage({
code: 'querySYMNOneFail', // 单次失败消息码 code: "querySYMNOneFail", // 单次失败消息码
data: { data: {
index: symnNum, // 失败的索引 index: symnNum, // 失败的索引
msg: res.msg || '未知错误', msg: res.msg || "未知错误",
successCount: symnResult.size, // 失败前已成功的次数 successCount: symnResult.size, // 失败前已成功的次数
taskId: cycleBaseParams?.taskId || '', taskId: cycleBaseParams?.taskId || "",
}, },
}); });
} }
@ -370,11 +385,11 @@ function initPlaybackWorker() {
* *
*/ */
function requestSingle() { function requestSingle() {
// 1. 终止条件:请求完成/被中断 // 1. 终止条件:请求完成/被中断
if (currentIndex >= totalCount) { if (currentIndex >= totalCount) {
// 发送接口1全部完成消息 // 发送接口1全部完成消息
postMessage({ postMessage({
code: 'queryHSJMNAllSuccess', code: "queryHSJMNAllSuccess",
data: { data: {
successCount: hsjmnResult1.size, successCount: hsjmnResult1.size,
}, },
@ -395,30 +410,34 @@ function initPlaybackWorker() {
}; };
// 3. 发送请求 // 3. 发送请求
fetchHttp(requestUrl, { params: requestParam, token }, (res) => { fetchHttp(
// 存储结果到Mapkey: 时间戳value: 接口返回的result requestUrl,
hsjmnResult1.set(timeList[currentIndex], res.result); { params: requestParam, token },
(res) => {
// 存储结果到Mapkey: 时间戳value: 接口返回的result
hsjmnResult1.set(timeList[currentIndex], res.result);
// 首次请求成功:发送专属消息 // 首次请求成功:发送专属消息
if (isFirstSend) { if (isFirstSend) {
postMessage({ postMessage({
code: 'queryHSJMNFirstSuccess', code: "queryHSJMNFirstSuccess",
data: { data: {
result: res.result, // 首次成功的完整结果 result: res.result, // 首次成功的完整结果
}, },
}); });
isAllowHsjmnDNFB = false; isAllowHsjmnDNFB = false;
isFirstSend = false; // 重置首次标记,后续不再发送 isFirstSend = false; // 重置首次标记,后续不再发送
} }
// 进入下一次请求 // 进入下一次请求
currentIndex++; currentIndex++;
requestSingle(); requestSingle();
}, },
// 失败回调 // 失败回调
(error) => { (error) => {
console.error(`❌ 接口1第 ${currentIndex + 1} 次请求失败:`, error); console.error(`❌ 接口1第 ${currentIndex + 1} 次请求失败:`, error);
}); },
);
} }
// 启动接口1的循环请求 // 启动接口1的循环请求
requestSingle(); requestSingle();
@ -440,11 +459,11 @@ function initPlaybackWorker() {
* *
*/ */
function requestSingle() { function requestSingle() {
// 1. 终止条件:请求完成/被中断 // 1. 终止条件:请求完成/被中断
if (currentIndex >= totalCount) { if (currentIndex >= totalCount) {
// 发送接口1全部完成消息 // 发送接口1全部完成消息
postMessage({ postMessage({
code: 'queryHSJMNAllSuccess', code: "queryHSJMNAllSuccess",
data: { data: {
successCount: hsjmnResult1.size, successCount: hsjmnResult1.size,
}, },
@ -465,30 +484,34 @@ function initPlaybackWorker() {
}; };
// 3. 发送请求 // 3. 发送请求
fetchHttp(requestUrl, { params: requestParam, token }, (res) => { fetchHttp(
// 存储结果到Mapkey: 时间戳value: 接口返回的result requestUrl,
hsjmnResult2.set(timeList[currentIndex], res.result); { params: requestParam, token },
(res) => {
// 存储结果到Mapkey: 时间戳value: 接口返回的result
hsjmnResult2.set(timeList[currentIndex], res.result);
// 首次请求成功:发送专属消息 // 首次请求成功:发送专属消息
if (isFirstSend) { if (isFirstSend) {
postMessage({ postMessage({
code: 'queryHSJMNFirstSuccess', code: "queryHSJMNFirstSuccess",
data: { data: {
result: res.result, // 首次成功的完整结果 result: res.result, // 首次成功的完整结果
}, },
}); });
isAllowHsjmnJLFB = false; isAllowHsjmnJLFB = false;
isFirstSend = false; // 重置首次标记,后续不再发送 isFirstSend = false; // 重置首次标记,后续不再发送
} }
// 进入下一次请求 // 进入下一次请求
currentIndex++; currentIndex++;
requestSingle(); requestSingle();
}, },
// 失败回调 // 失败回调
(error) => { (error) => {
console.error(`❌ 接口1第 ${currentIndex + 1} 次请求失败:`, error); console.error(`❌ 接口1第 ${currentIndex + 1} 次请求失败:`, error);
}); },
);
} }
// 启动接口1的循环请求 // 启动接口1的循环请求
requestSingle(); requestSingle();

View File

@ -1,14 +1,23 @@
import type { WorkerFunc } from './worketBlob'; import type { WorkerFunc } from "./worketBlob";
import dayjs from 'dayjs'; import dayjs from "dayjs";
import { serverIp } from '@/gis/common/config'; import { serverIp } from "@/gis/common/config";
import EventBus from '@/gis/common/eventBus/eventBus.ts'; import EventBus from "@/gis/common/eventBus/eventBus.ts";
import { fitViewByExtent } from '@/gis/ol/index.ts'; import { fitViewByExtent } from "@/gis/ol/index.ts";
import { setShouldAnimate } from '@/gis/ol/olClock'; import { setShouldAnimate } from "@/gis/ol/olClock";
import { addCustomGridLayer } from '@/gis/ol/olGirdHeatmap'; import { addCustomGridLayer } from "@/gis/ol/olGirdHeatmap";
import { addHeatmapLayer } from '@/gis/ol/olHeatmap'; import { addHeatmapLayer } from "@/gis/ol/olHeatmap";
// import { addIsolineLayer } from '@/gis/ol/olIsoLine'; // import { addIsolineLayer } from '@/gis/ol/olIsoLine';
import playbackWorker from './playbackWorker'; import playbackWorker from "./playbackWorker";
import { getWorketImportUrl } from './worketBlob'; // 导入 WorkerFunc 类型 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 消息数据的类型接口 // 定义 Worker 消息数据的类型接口
interface WorkerMessage { interface WorkerMessage {
code: string; code: string;
@ -27,22 +36,27 @@ type PostMessageData = Record<string, any>;
let playbackWorkerObj: Worker | null = null; let playbackWorkerObj: Worker | null = null;
export const playback_EventBusCallback = new EventBus(); export const playback_EventBusCallback = new EventBus();
let hsjmnType = '浓度分布'; let hsjmnType = "浓度分布";
let axes: FlexpartGridAxes = {};
let colorScale: FlexpartColorScale = {};
/** /**
* extent * extent
* @param points [{ lon: 经度, lat: 纬度, ... }] * @param points [{ lon: 经度, lat: 纬度, ... }]
* @returns extent [minLon, minLat, maxLon, maxLat] null * @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) { if (!points || points.length === 0) {
console.warn('无有效经纬度点,无法计算 extent'); console.warn("无有效经纬度点,无法计算 extent");
return null; return null;
} }
// 提取所有经度和纬度 // 提取所有经度和纬度
const lons = points.map(p => p.lon); const lons = points.map((p) => p.lon);
const lats = points.map(p => p.lat); const lats = points.map((p) => p.lat);
// 计算最小/最大经纬度(添加 0.01 缓冲,避免点贴边) // 计算最小/最大经纬度(添加 0.01 缓冲,避免点贴边)
const minLon = Math.min(...lons) - 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 { export function initPlaybackWorkerMa(): void {
// 校验 playbackWorker 是符合要求的 Worker 函数(类型约束) // 校验 playbackWorker 是符合要求的 Worker 函数(类型约束)
if (typeof playbackWorker !== 'function' || !playbackWorker.name) { if (typeof playbackWorker !== "function" || !playbackWorker.name) {
throw new Error('playbackWorker 必须是具名函数,且无外部依赖'); throw new Error("playbackWorker 必须是具名函数,且无外部依赖");
} }
// 创建 worket 对象getWorketImportUrl 已约束入参为 WorkerFunc // 创建 worket 对象getWorketImportUrl 已约束入参为 WorkerFunc
const workerUrl = getWorketImportUrl(playbackWorker as WorkerFunc); const workerUrl = getWorketImportUrl(playbackWorker as WorkerFunc);
playbackWorkerObj = new Worker(workerUrl); playbackWorkerObj = new Worker(workerUrl);
const token = localStorage.getItem('token'); const token = localStorage.getItem("token");
playbackWorkerObj.postMessage({ playbackWorkerObj.postMessage({
code: 'init', code: "init",
value: { value: {
serverIp, serverIp,
token, token,
@ -78,173 +92,129 @@ export function initPlaybackWorkerMa(): void {
playbackWorkerObj.onmessage = function (event: MessageEvent<WorkerMessage>) { playbackWorkerObj.onmessage = function (event: MessageEvent<WorkerMessage>) {
const weaData = event.data; const weaData = event.data;
switch (weaData.code) { switch (weaData.code) {
case 'querySYMNFirstSuccess':{ // 输运模拟第一次接口请求完成,开始绘制自定义热力图 case "querySYMNFirstSuccess":
// 定义最大值 {
let maxValue = 0; // 输运模拟第一次接口请求完成,开始绘制自定义热力图
let minValue = 0; // 定义最大值
// 处理数据 let maxValue = 0;
const gridData = weaData.data.result.map((item: any) => { let minValue = 0;
if (item.value > maxValue) { // 处理数据
maxValue = item.value; 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; colorScale = {
} colorMin: colorArr[0],
return { colorMax: colorArr[10],
lon: item.lon, levels: colorArr,
lat: item.lat,
value: item.value,
width: weaData.data.lonWidth,
height: weaData.data.latWidth,
}; };
});
// 加载自定义热力图 axes = {
addCustomGridLayer(gridData, maxValue, minValue); longitudes:weaData.data.lonGroupData,
latitudes:weaData.data.latGroupData,
// 开始播放
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;
} }
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,
};
});
// 加载自定义热力图 renderBackwardData(gridData,axes,colorScale);
addCustomGridLayer(gridData, maxValue, minValue); // 加载自定义热力图
// 更改最大值 // addCustomGridLayer(gridData, maxValue, minValue);
// playback_EventBusCallback.publish('symnLegendUpdateMax', {
// maxValue, // 开始播放
// minValue, setShouldAnimate(true);
// });
} // 显示图例
// playback_EventBusCallback.publish('legendUiData', {
// key: 'showLengend',
// value: true,
// });
// 更改图例最大值
// playback_EventBusCallback.publish('symnLegendUpdateMax', {
// maxValue,
// minValue,
// });
}
break; 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; break;
case 'querySYMNAllSuccess': // 输运模拟所有接口请求完成 case "querySYMNAllSuccess": // 输运模拟所有接口请求完成
// 解除进度条禁用 // 解除进度条禁用
playback_EventBusCallback.publish('timeSliderUiData', { playback_EventBusCallback.publish("timeSliderUiData", {
key: 'isDisabledSlider', key: "isDisabledSlider",
value: false, value: false,
}); });
break; break;
case 'queryHSJMNType': case "queryHSJMNType":
hsjmnType = weaData.data; hsjmnType = weaData.data;
break; break;
case 'queryHSJMNFirstSuccess': { // 核事件模拟第一次请求完成,开始绘制 case "queryHSJMNFirstSuccess":
// 显示图例 {
playback_EventBusCallback.publish('legendUiData', { // 核事件模拟第一次请求完成,开始绘制
key: 'showLengend', // 显示图例
value: true, 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],
};
}); });
const extent = calculatePointsExtent(gridData); let data = null;
if (extent) { if (hsjmnType === "浓度分布") {
// 定位 data = weaData.data.result;
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) {
const gridData = data.dataList.map((item: any) => { const gridData = data.dataList.map((item: any) => {
return { return {
lon: item[0], 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); addHeatmapLayer(gridData, data.max === 0 ? 1 : data.max);
// 动态改变图例最大值 // 动态改变图例最大值
playback_EventBusCallback.publish('symnLegendUpdateMax', { playback_EventBusCallback.publish("symnLegendUpdateMax", {
maxValue: data.max, maxValue: data.max,
minValue: data.min, minValue: data.min,
}); });
} } else {
} data = weaData.data.result;
else {
data = weaData.data.hsjmnResult2Count; // 加工数据
if (data) { const fitData = data.dataList.map((item: any) => {
const gridData = data.dataList.filter(item => item[2] >= 1.5).map((item: any) => {
return { return {
lon: item[0], lon: item[0],
lat: item[1], 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); addHeatmapLayer(gridData, data.max === 0 ? 1 : data.max);
// 渲染等值线 // 渲染等值线
// addIsolineLayer(gridData); // 可自定义等值线间隔 // addIsolineLayer(gridData); // 可自定义等值线间隔
// 动态改变图例最大值 // 动态改变图例最大值
playback_EventBusCallback.publish('symnLegendUpdateMax', { playback_EventBusCallback.publish("symnLegendUpdateMax", {
maxValue: data.max, maxValue: data.max,
minValue: data.min, minValue: data.min,
}); });
} }
// 开始播放
setShouldAnimate(true);
} }
}
break; 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', { playback_EventBusCallback.publish("timeSliderUiData", {
key: 'isDisabledSlider', key: "isDisabledSlider",
value: false, value: false,
}); });
break; break;
@ -298,7 +343,7 @@ export function initPlaybackWorkerMa(): void {
// 监听 Worker 错误(可选:增强错误处理) // 监听 Worker 错误(可选:增强错误处理)
playbackWorkerObj.onerror = function (error) { 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 类型守卫) // 确保 playbackWorkerObj 已初始化TS 类型守卫)
if (playbackWorkerObj) { if (playbackWorkerObj) {
playbackWorkerObj.postMessage(massage); playbackWorkerObj.postMessage(massage);
} } else {
else { throw new Error("Web Worker 初始化失败,无法发送消息");
throw new Error('Web Worker 初始化失败,无法发送消息');
} }
} }

View File

@ -36,12 +36,17 @@ const props = defineProps({
}, },
}); });
const unitValue = ref(null);
watch( watch(
() => props.startExp, // props () => props.startExp, // props
(newVal, oldVal) => { (newVal, oldVal) => {
console.log("父组件传过来的值变化了", newVal, oldVal); console.log("父组件传过来的值变化了", newVal, oldVal);
// 10 // 10
handleGenTicks(newVal); handleGenTicks(newVal);
sessionStorage.setItem("symnMaxColor",newVal);
unitValue.value = JSON.parse(sessionStorage.getItem("ssmnDataItem")).taskMode==-1?"m<sup>-3</sup>":"mBq/m<sup>3</sup>"
}, },
{ immediate: true } // immediate:true { immediate: true } // immediate:true
); );
@ -55,7 +60,7 @@ function handleGenTicks(maxVal) {
label: maxVal - index, label: maxVal - index,
}; };
colorList.value.push(item1); colorList.value.unshift(item1);
}); });
} }
</script> </script>
@ -72,6 +77,7 @@ function handleGenTicks(maxVal) {
></div> ></div>
</div> </div>
<!-- 刻度10一一对应每个色块 --> <!-- 刻度10一一对应每个色块 -->
<span style="position: absolute;right: 40px;bottom: 12px; white-space: nowrap;" v-html="unitValue"></span>
<div class="ticks"> <div class="ticks">
<span v-for="(item, index) in colorList" :key="index" <span v-for="(item, index) in colorList" :key="index"
>10<sup>{{ item.label }}</sup></span >10<sup>{{ item.label }}</sup></span

View File

@ -41,7 +41,7 @@ import sxfx from "./timingAnalysis/index.vue";
import { playback_EventBusCallback } from "@/gis/worker/playbackWorkerMa"; import { playback_EventBusCallback } from "@/gis/worker/playbackWorkerMa";
import { activeMoudleInf } from "@/gis/ol"; import { activeMoudleInf } from "@/gis/ol";
import { getRwNcMaxValue } from "@/utils/axios/fskjk"; import { getRwNcMaxValue,stopRw } from "@/utils/axios/fskjk";
const { t, locale } = useI18n(); const { t, locale } = useI18n();
const formEl = ref<FormInstance>(); const formEl = ref<FormInstance>();
@ -1020,6 +1020,14 @@ function getStations() {
}); });
} }
//
function stopTaskRw(taskId){
console.log(taskId,"222")
stopRw({taskId},(res)=>{
})
}
getList(); getList();
// //
getNuclearfacility(); getNuclearfacility();
@ -1046,7 +1054,7 @@ getStations();
<el-input v-model="form.taskName" /> <el-input v-model="form.taskName" />
</el-form-item> </el-form-item>
<el-form-item :label="t('taskMode')"> <el-form-item :label="t('taskMode')">
<el-input :value="form.taskMode === -1 ? '反向模拟' : '正向模拟'" /> <el-input :value="form.taskMode === -1 ? '反向模拟' : form.taskMode === 2?'持续正向模拟':'正向模拟'" />
</el-form-item> </el-form-item>
<el-form-item :label="t('taskType')"> <el-form-item :label="t('taskType')">
<el-input :value="form.taskType === 1 ? '手动创建' : '自动创建'" /> <el-input :value="form.taskType === 1 ? '手动创建' : '自动创建'" />
@ -1277,6 +1285,7 @@ getStations();
class="custom-select" class="custom-select"
> >
<ElOption label="正向模拟" :value="1" /> <ElOption label="正向模拟" :value="1" />
<ElOption label="正向持续模拟" :value="2" />
<ElOption label="反向模拟" :value="-1" /> <ElOption label="反向模拟" :value="-1" />
</ElSelect> </ElSelect>
</ElFormItem> </ElFormItem>
@ -1346,7 +1355,7 @@ getStations();
<ElTableColumn prop="taskName" :label="t('taskName')" /> <ElTableColumn prop="taskName" :label="t('taskName')" />
<ElTableColumn prop="taskMode" :label="t('taskMode')"> <ElTableColumn prop="taskMode" :label="t('taskMode')">
<template #default="scope"> <template #default="scope">
{{ scope.row.taskMode === 1 ? "正向模拟" : "反向模拟" }} {{ scope.row.taskMode === 1 ? "正向模拟" : scope.row.taskMode === 2?'持续正向模拟':'正向模拟' }}
</template> </template>
</ElTableColumn> </ElTableColumn>
<ElTableColumn prop="taskType" :label="t('taskType')"> <ElTableColumn prop="taskType" :label="t('taskType')">
@ -1514,6 +1523,15 @@ getStations();
> >
{{ t("operationLog") }} {{ t("operationLog") }}
</ElButton> </ElButton>
<ElButton
type="text"
style="color: #36e7f7;"
v-if="scope.row.taskMode==2"
@click="stopTaskRw(scope.row.id)"
>
停止任务
</ElButton>
</template> </template>
</ElTableColumn> </ElTableColumn>
</ElTable> </ElTable>
@ -1593,6 +1611,7 @@ getStations();
@change="clearChild" @change="clearChild"
> >
<ElOption label="正向模拟" :value="1" /> <ElOption label="正向模拟" :value="1" />
<ElOption label="正向持续模拟" :value="2" />
<ElOption label="反向模拟" :value="-1" /> <ElOption label="反向模拟" :value="-1" />
</ElSelect> </ElSelect>
</ElFormItem> </ElFormItem>

View File

@ -114,3 +114,23 @@ export async function getRwNcMaxValue(
callback?.(response); callback?.(response);
} }
} }
// 停止任务
export async function stopRw(
params: any,
callback?: (data: any) => void,
): Promise<any> {
const response = await request({
url: `${serverIp}${baseURL}/transportTask/stopTask`,
method: "PUT",
params,
});
if (response.success && response.code === 200) {
ElMessage({
type: "success",
message: "停止成功",
});
callback?.(response);
}
}