gis-view-webgl2 0.1.11 → 0.1.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +0 -24
  2. package/dist/gis-view.css +1 -1
  3. package/dist/index.cjs +1 -1
  4. package/dist/index.d.ts +524 -33
  5. package/dist/index.js +1 -1
  6. package/docs/AI/345/257/271/350/257/235/346/261/207/346/200/273.md +156 -8
  7. package/docs/GisView.md +6 -6
  8. package/docs/ai/06-/344/274/232/350/257/235/347/264/242/345/274/225/344/270/216/345/244/215/346/237/245/346/226/271/346/263/225.md +157 -7
  9. package/docs/demos/BackgroundDemo.vue +600 -0
  10. package/docs/demos/ChannelDemo.vue +145 -3
  11. package/docs/demos/StyleDemo.vue +280 -69
  12. package/docs/demos/api-index.js +35 -19
  13. package/docs/demos/demos.js +26 -18
  14. package/docs/demos/gis-view-demo/demo.css +76 -0
  15. package/docs/demos/gis-view-demo/panels/StylePanels.vue +24 -8
  16. package/docs/demos/gis-view-demo/panels/ViewPanels.vue +33 -3
  17. package/docs/demos/gis-view-demo/useGisViewDemo.js +236 -22
  18. package/docs/format/02-/350/212/202/347/202/271/344/270/216/347/272/277/350/267/257.md +1 -1
  19. package/docs/format/03-/346/213/220/347/202/271/350/243/205/351/245/260/344/270/216/346/240/267/345/274/217.md +3 -3
  20. package/docs/from0/06-/344/273/216/346/225/260/346/215/256/345/210/260/345/256/236/344/276/213-/350/247/243/346/236/220/345/261/202.md +13 -11
  21. package/docs/from0/15-/351/231/204/345/275/225-/345/270/270/351/207/217/351/200/237/346/237/245/344/270/216/350/270/251/345/235/221/346/270/205/345/215/225.md +1 -1
  22. package/docs/index.html +2 -2
  23. package/docs/internals/10-/350/247/204/345/210/231/346/235/241/344/273/266/344/270/216/347/212/266/346/200/201/346/240/267/345/274/217.md +3 -3
  24. package/docs/internals/13-/351/271/260/347/234/274/345/233/276.md +1 -1
  25. package/docs/internals/14-/350/203/214/346/231/257/345/261/202.md +1 -0
  26. package/docs/internals/15-03-/351/200/220/350/275/256/350/256/260/345/275/225-/346/200/247/350/203/275/344/270/216/344/277/256/345/244/215-/344/270/213.md +199 -0
  27. package/docs/internals/17-/350/260/203/350/257/225/344/270/216/346/211/251/345/261/225/345/273/272/350/256/256.md +1 -1
  28. package/docs/internals/18-/346/200/247/350/203/275/345/256/236/346/265/213/344/270/216WebGPU/350/257/204/344/274/260.md +32 -0
  29. package/docs/manual/02-/345/235/220/346/240/207/344/270/216/346/225/260/346/215/256.md +1 -1
  30. package/docs/manual/03-/351/205/215/347/275/256/351/241/271.md +217 -26
  31. package/docs/manual/07-/346/214/207/351/222/210/344/270/216/350/246/201/347/264/240/347/274/226/350/276/221.md +2 -2
  32. package/docs/manual/08-/351/200/211/344/270/255/344/270/216/346/213/220/347/202/271.md +37 -4
  33. package/docs/manual/09-/351/200/232/351/201/223/344/270/216/345/244/232/345/233/236/347/272/277.md +58 -1
  34. package/docs/manual/11-/346/240/267/345/274/217/344/270/216/350/247/204/345/210/231.md +59 -8
  35. package/docs/manual/12-/346/240/207/347/255/276/344/270/216/350/243/205/351/245/260.md +1 -1
  36. package/docs/manual/13-/345/212/250/346/225/210/344/270/216/345/233/276/345/205/203.md +220 -2
  37. package/docs/manual/14-/350/247/206/345/233/276/344/270/216/347/255/233/351/200/211.md +77 -16
  38. package/docs/manual/15-/345/257/274/345/207/272/344/270/216/351/233/206/346/210/220.md +30 -17
  39. package/docs/manual/16-/345/256/236/344/276/213/346/226/271/346/263/225/346/200/273/350/241/250.md +11 -4
  40. package/docs/manual/17-/345/217/202/346/225/260/351/200/237/346/237/245.md +17 -8
  41. package/docs/manual/18-/351/200/220/346/226/271/346/263/225/347/244/272/344/276/213.md +128 -4
  42. package/docs/manual/19-/350/257/273/346/225/260/344/270/216/346/213/226/346/213/275/351/242/204/350/247/210.md +2 -2
  43. package/docs/manual/20-/346/200/247/350/203/275/344/270/216/345/244/247/346/225/260/346/215/256.md +13 -1
  44. package/docs/manual/21-/350/257/264/346/230/216/344/270/216/345/270/270/350/247/201/351/227/256/351/242/230.md +4 -1
  45. package/docs/steps/index.html +1 -1
  46. package/docs/steps/selftest-demo.html +2 -2
  47. package/docs/steps/selftest-demos.html +626 -11
  48. package/docs/steps/selftest-docs.html +8 -4
  49. package/docs/steps/selftest-features.html +1428 -2
  50. package/docs/steps/selftest-perf.html +296 -25
  51. package/docs/steps/selftest-scale.html +61 -3
  52. package/docs/steps/selftest-select.html +303 -1
  53. package/docs/steps/step-06-style.js +3 -2
  54. package/package.json +2 -2
@@ -37,6 +37,10 @@
37
37
  try {
38
38
  const { createChannels, createGisView: createGisViewRaw } = await import('../../src/gis-view/index.js')
39
39
  const { DEFAULT_SHORTCUTS } = await import('../../src/gis-view/keys.js')
40
+ const { compilePointRules, resolvePoint } = await import('../../src/gis-view/point.js')
41
+ const { compileLineRules, resolveLineStyle } = await import('../../src/gis-view/line.js')
42
+ const { parseColor } = await import('../../src/gis-view/utils.js')
43
+ const { resolveGeoJson } = await import('../../src/gis-view/background.js')
40
44
  // 本页断言是照「限数 + 避让」的老口径写的:统一按优化档(labelOptimize: true)跑
41
45
  const createGisView = (host, options = {}) => createGisViewRaw(host, { labelOptimize: true, ...options })
42
46
  const { dcLineDecoration, dcLineDecorationSymbol, pathSymbol } = await import('../demos/gisStyle.js')
@@ -243,6 +247,300 @@
243
247
  check('关掉网格回到原来的画面', restoreLine === 0, `回退像素差 ${restoreLine}`)
244
248
  check('表现型配置不改数据', view.getData().lines.length === 4 && view.getData().points.length === 2, JSON.stringify({ lines: view.getData().lines.length, points: view.getData().points.length }))
245
249
 
250
+ // 网架整体显隐 / 透明度:都是表现型配置,隐藏后数据与拾取入口一起收掉,背景与网格不参与
251
+ const networkSample = view.project(100, 0)
252
+ const samplePixel = (frame) => {
253
+ const x = Math.min(Math.max(Math.round(networkSample[0]), 0), frame.width - 1)
254
+ const y = Math.min(Math.max(Math.round(networkSample[1]), 0), frame.height - 1)
255
+ const index = (y * frame.width + x) * 4
256
+ return [frame.pixels[index], frame.pixels[index + 1], frame.pixels[index + 2]]
257
+ }
258
+ const colorDistance = (pixel, rgb) => Math.abs(pixel[0] - rgb[0]) + Math.abs(pixel[1] - rgb[1]) + Math.abs(pixel[2] - rgb[2])
259
+ const networkFrame = await shot()
260
+ const networkPixel = samplePixel(networkFrame)
261
+ const networkPick = view.pick(networkSample[0], networkSample[1])
262
+ view.updateOptions({ networkVisible: false })
263
+ const hiddenFrame = await shot()
264
+ const hiddenPixel = samplePixel(hiddenFrame)
265
+ const hiddenInk = ink(hiddenFrame, [0, 0, hiddenFrame.width, hiddenFrame.height])
266
+ const hiddenPick = view.pick(networkSample[0], networkSample[1])
267
+ check('networkVisible: false 真正跳过整段网架绘制,且拾取不再命中',
268
+ Boolean(networkPick) && hiddenPick === null && hiddenInk === 0 && colorDistance(hiddenPixel, [255, 255, 255]) < 12 &&
269
+ view.getData().lines.length === 4 && view.getData().points.length === 2,
270
+ `命中 ${Boolean(networkPick)}→${Boolean(hiddenPick)} / 剩余墨量 ${hiddenInk} / 像素 ${hiddenPixel.join(',')}`)
271
+ view.updateOptions({ networkVisible: true, networkOpacity: 0.25 })
272
+ const fadedFrame = await shot()
273
+ const fadedPixel = samplePixel(fadedFrame)
274
+ check('networkOpacity 在逐要素颜色之上再乘一层 alpha,数据不动',
275
+ colorDistance(fadedPixel, [255, 255, 255]) > 5 &&
276
+ colorDistance(fadedPixel, [255, 255, 255]) < colorDistance(networkPixel, [255, 255, 255]) &&
277
+ view.getData().lines.length === 4 && view.getData().points.length === 2,
278
+ `实色 ${networkPixel.join(',')} → 25% ${fadedPixel.join(',')}`)
279
+ view.updateOptions({ networkOpacity: 1, networkVisible: true })
280
+
281
+ // 创建时就隐藏网架:WebGL 数据段不画,DOM 标签层也要在第一帧前同步收起
282
+ const initialHiddenHost = document.createElement('div')
283
+ initialHiddenHost.style.cssText = 'width:320px;height:220px'
284
+ document.body.appendChild(initialHiddenHost)
285
+ const initialHiddenView = createGisView(initialHiddenHost, {
286
+ points: [{ id: 'initial-hidden', x: 0, y: 0, name: '初始隐藏' }],
287
+ lines: [],
288
+ label: { show: true, content: '{name}' },
289
+ networkVisible: false,
290
+ networkOpacity: 0.35,
291
+ fitPadding: 12
292
+ })
293
+ const initialNetworkLayers = [
294
+ initialHiddenHost.querySelector('.gis-view__label-layer--point'),
295
+ initialHiddenHost.querySelector('.gis-view__label-layer--line'),
296
+ initialHiddenHost.querySelector('.gis-view__cluster-layer')
297
+ ]
298
+ check('初始化 networkVisible: false 时 DOM 标签层同步隐藏',
299
+ initialNetworkLayers.every(layer => layer?.style.visibility === 'hidden' && layer?.style.opacity === '0'),
300
+ initialNetworkLayers.map(layer => layer ? `${layer.style.visibility}/${layer.style.opacity}` : 'missing').join(' · '))
301
+ initialHiddenView.destroy()
302
+ initialHiddenHost.remove()
303
+
304
+ // GeoJSON 目标矩形:逐区域几何、bounds 与中心一起搬过去,原始范围留给相机 / 投影
305
+ const mappedGeo = resolveGeoJson({
306
+ type: 'Polygon',
307
+ coordinates: [[[0, 0], [10, 0], [10, 5], [0, 5], [0, 0]]]
308
+ }, {
309
+ aspectScale: 1,
310
+ bounds: { minX: 100, minY: 200, maxX: 140, maxY: 220 }
311
+ }, 1)
312
+ const mappedXs = mappedGeo.fills.filter((_value, index) => index % 2 === 0)
313
+ const mappedYs = mappedGeo.fills.filter((_value, index) => index % 2 === 1)
314
+ check('background.geoJsonStyle.bounds 把整份 GeoJSON 独立缩放到目标宽高,rawBounds 不变',
315
+ mappedGeo.bounds?.minX === 100 && mappedGeo.bounds?.maxX === 140 && mappedGeo.bounds?.minY === 200 && mappedGeo.bounds?.maxY === 220 &&
316
+ mappedGeo.rawBounds?.minX === 0 && mappedGeo.rawBounds?.maxX === 10 && mappedGeo.rawBounds?.minY === 0 && mappedGeo.rawBounds?.maxY === 5 &&
317
+ mappedXs.length > 0 && mappedXs.every(value => value >= 99.999 && value <= 140.001) &&
318
+ mappedYs.length > 0 && mappedYs.every(value => value >= 199.999 && value <= 220.001) &&
319
+ Math.abs(mappedGeo.regions[0].center[0] - 120) < 1e-6 && Math.abs(mappedGeo.regions[0].center[1] - 210) < 1e-6,
320
+ JSON.stringify({ bounds: mappedGeo.bounds, rawBounds: mappedGeo.rawBounds, center: mappedGeo.regions[0].center }))
321
+
322
+ // GeoJSON 旋转:绕目标 bounds 中心顺时针转,几何与区域 center 一起转,bounds 报旋转后的 AABB
323
+ const rotatedGeo = resolveGeoJson({
324
+ type: 'Polygon',
325
+ coordinates: [[[0, 0], [10, 0], [0, 5], [0, 0]]]
326
+ }, {
327
+ aspectScale: 1,
328
+ bounds: { minX: 100, minY: 200, maxX: 140, maxY: 220 },
329
+ rotation: 90
330
+ }, 1)
331
+ const rotatedFillCount = rotatedGeo.fills.length / 2
332
+ let rotatedCorner = false
333
+ for (let index = 0; index < rotatedFillCount; index += 1) {
334
+ if (Math.abs(rotatedGeo.fills[index * 2] - 110) < 1e-6 && Math.abs(rotatedGeo.fills[index * 2 + 1] - 190) < 1e-6) {
335
+ rotatedCorner = true
336
+ break
337
+ }
338
+ }
339
+ check('background.geoJsonStyle.rotation 绕中心顺时针旋转,bounds 是旋转后的 AABB、rawBounds 不变',
340
+ rotatedGeo.rotation === 90 && rotatedCorner &&
341
+ Math.abs(rotatedGeo.bounds.minX - 110) < 1e-6 && Math.abs(rotatedGeo.bounds.maxX - 130) < 1e-6 &&
342
+ Math.abs(rotatedGeo.bounds.minY - 190) < 1e-6 && Math.abs(rotatedGeo.bounds.maxY - 230) < 1e-6 &&
343
+ rotatedGeo.rawBounds?.minX === 0 && rotatedGeo.rawBounds?.maxX === 10 && rotatedGeo.rawBounds?.minY === 0 && rotatedGeo.rawBounds?.maxY === 5 &&
344
+ Math.abs(rotatedGeo.regions[0].center[0] - 116.66666666666666) < 1e-6 && Math.abs(rotatedGeo.regions[0].center[1] - 216.66666666666669) < 1e-6,
345
+ JSON.stringify({ rotation: rotatedGeo.rotation, bounds: rotatedGeo.bounds, rawBounds: rotatedGeo.rawBounds, center: rotatedGeo.regions[0].center }))
346
+
347
+ // 背景编辑模式:网架的点击 / 拖点 / 平移 / 右键 / 删除键全部停用,滚轮缩放仍保留
348
+ const editingBox = canvas.getBoundingClientRect()
349
+ const editingPointScreen = id => {
350
+ const item = view.getData().points.find(point => point.id === id)
351
+ return view.project(item.x, item.y)
352
+ }
353
+ const editingPointer = (type, screen, extra = {}) => canvas.dispatchEvent(new PointerEvent(type, {
354
+ bubbles: true,
355
+ cancelable: true,
356
+ pointerId: 91,
357
+ pointerType: 'mouse',
358
+ button: 0,
359
+ buttons: type === 'pointerup' ? 0 : 1,
360
+ clientX: editingBox.left + screen[0],
361
+ clientY: editingBox.top + screen[1],
362
+ ...extra
363
+ }))
364
+ const editingClick = screen => {
365
+ editingPointer('pointermove', screen, { buttons: 0 })
366
+ editingPointer('pointerdown', screen)
367
+ editingPointer('pointerup', screen)
368
+ }
369
+ const selectionBeforeEditingTest = view.getSelections().map(({ type, index }) => ({ type, index }))
370
+ view.deselect()
371
+ editingClick(editingPointScreen('node-a'))
372
+ await hoverFrame()
373
+ const selectedBeforeEditing = view.getSelectionData().ids
374
+ view.updateOptions({ backgroundEditing: true })
375
+ const pointsBeforeEditing = JSON.stringify(view.getData().points.map(point => [point.id, point.x, point.y]))
376
+ const cameraBeforeEditing = view.getCamera()
377
+ const contextEvents = []
378
+ const contextOff = view.on('contextMenu', payload => contextEvents.push(payload))
379
+ editingClick(editingPointScreen('node-b'))
380
+ editingPointer('pointerdown', editingPointScreen('node-a'))
381
+ editingPointer('pointermove', [editingPointScreen('node-a')[0] + 60, editingPointScreen('node-a')[1] + 25])
382
+ editingPointer('pointerup', [editingPointScreen('node-a')[0] + 60, editingPointScreen('node-a')[1] + 25])
383
+ editingPointer('pointerdown', [18, 18])
384
+ editingPointer('pointermove', [88, 48])
385
+ editingPointer('pointerup', [88, 48])
386
+ const contextScreen = editingPointScreen('node-a')
387
+ const contextAllowed = canvas.dispatchEvent(new MouseEvent('contextmenu', {
388
+ bubbles: true,
389
+ cancelable: true,
390
+ button: 2,
391
+ clientX: editingBox.left + contextScreen[0],
392
+ clientY: editingBox.top + contextScreen[1]
393
+ }))
394
+ view.el.dispatchEvent(new KeyboardEvent('keydown', { key: 'Delete', bubbles: true, cancelable: true }))
395
+ await hoverFrame()
396
+ const cameraAfterEditing = view.getCamera()
397
+ const selectedAfterEditing = view.getSelectionData().ids
398
+ const pointsAfterEditing = JSON.stringify(view.getData().points.map(point => [point.id, point.x, point.y]))
399
+ contextOff()
400
+ check('backgroundEditing 屏蔽网架点击 / 拖点 / 平移 / 右键 / 删除键,背景仍保留',
401
+ view.getOptions().backgroundEditing === true && selectedBeforeEditing.length === 1 &&
402
+ JSON.stringify(selectedAfterEditing) === JSON.stringify(selectedBeforeEditing) &&
403
+ pointsAfterEditing === pointsBeforeEditing && contextEvents.length === 0 && contextAllowed === false &&
404
+ cameraAfterEditing.center[0] === cameraBeforeEditing.center[0] && cameraAfterEditing.center[1] === cameraBeforeEditing.center[1] &&
405
+ cameraAfterEditing.scale === cameraBeforeEditing.scale,
406
+ JSON.stringify({ selected: selectedAfterEditing, context: contextEvents.length, allowed: contextAllowed, camera: [cameraBeforeEditing, cameraAfterEditing] }))
407
+ const editingScaleBefore = view.getScale()
408
+ canvas.dispatchEvent(new WheelEvent('wheel', {
409
+ bubbles: true,
410
+ cancelable: true,
411
+ deltaY: -240,
412
+ clientX: editingBox.left + contextScreen[0],
413
+ clientY: editingBox.top + contextScreen[1]
414
+ }))
415
+ await hoverFrame()
416
+ check('backgroundEditing 下滚轮仍能让网架和背景一起缩放',
417
+ view.getScale() > editingScaleBefore && view.getOptions().backgroundEditing === true,
418
+ `${editingScaleBefore} -> ${view.getScale()}`)
419
+ view.updateOptions({ backgroundEditing: false })
420
+ check('退出 backgroundEditing 后开关可恢复,数据和选中都不丢',
421
+ view.getOptions().backgroundEditing === false && JSON.stringify(view.getSelectionData().ids) === JSON.stringify(selectedBeforeEditing) &&
422
+ view.getData().points.length === 2 && view.getData().lines.length === 4,
423
+ JSON.stringify({ editing: view.getOptions().backgroundEditing, selected: view.getSelectionData().ids }))
424
+ view.setCamera(cameraBeforeEditing)
425
+ view.deselect()
426
+ if (selectionBeforeEditingTest.length) {
427
+ view.selectFeatures(selectionBeforeEditingTest)
428
+ }
429
+ await hoverFrame()
430
+
431
+ // 内置背景编辑:begin / update / end 三个入口直接管住编辑层、4 / 8 / 9 点与背景写回
432
+ const backgroundBeforeEditorApi = view.getOptions().background
433
+ const cameraBeforeEditorApi = view.getCamera()
434
+ let editorChangeCount = 0
435
+ let editorEndCount = 0
436
+ view.updateBackground({
437
+ color: '#f4f8fc',
438
+ geoJson: {
439
+ type: 'FeatureCollection',
440
+ features: [{
441
+ type: 'Feature',
442
+ properties: { name: '编辑区域' },
443
+ geometry: {
444
+ type: 'Polygon',
445
+ coordinates: [[[0, 0], [10, 0], [10, 5], [0, 5], [0, 0]]]
446
+ }
447
+ }]
448
+ },
449
+ geoJsonStyle: {
450
+ aspectScale: 1,
451
+ bounds: { minX: 40, minY: -20, maxX: 160, maxY: 80 },
452
+ rotation: 0,
453
+ fill: '#b9d5ee',
454
+ stroke: '#6f9bc5',
455
+ label: { enabled: true, fontSize: 14, color: '#475569' }
456
+ }
457
+ })
458
+ check('内置背景编辑:未 begin 时读数为 null,也没有编辑层',
459
+ view.getBackgroundEdit() === null && view.el.querySelector('[data-gis-background-editor]') === null)
460
+ const beginEditorReturn = view.beginBackgroundEdit({
461
+ aspectLocked: true,
462
+ rotation: false,
463
+ fitView: true,
464
+ style: { color: '#1d4ed8', handleSize: 18, handleShape: 'square' },
465
+ onChange: () => { editorChangeCount += 1 },
466
+ onEnd: () => { editorEndCount += 1 }
467
+ })
468
+ await hoverFrame()
469
+ const editorStartState = view.getBackgroundEdit()
470
+ const editorLayer = view.el.querySelector('[data-gis-background-editor]')
471
+ const visibleEditorHandles = () => [...view.el.querySelectorAll('[data-gis-background-handle]:not([hidden])')]
472
+ const visibleRotateHandles = () => [...view.el.querySelectorAll('[data-gis-background-rotate]:not([hidden])')]
473
+ check('beginBackgroundEdit:自动打开 backgroundEditing,默认四角、无旋转点,并写入编辑器样式',
474
+ beginEditorReturn === view && editorStartState?.active === true && editorStartState.backgroundType === 'geojson' &&
475
+ editorStartState.aspectLocked === true && editorStartState.rotationEnabled === false && editorStartState.handleCount === 4 &&
476
+ view.getOptions().backgroundEditing === true && editorLayer !== null && visibleEditorHandles().length === 4 && visibleRotateHandles().length === 0 &&
477
+ editorLayer.style.getPropertyValue('--gis-view-bg-editor-color').trim().toLowerCase() === '#1d4ed8' &&
478
+ editorLayer.style.getPropertyValue('--gis-view-bg-editor-handle-size').trim() === '18px',
479
+ JSON.stringify({ state: editorStartState, handles: visibleEditorHandles().length, rotate: visibleRotateHandles().length, editing: view.getOptions().backgroundEditing }))
480
+ const editorAreaLabel = view.el.querySelector('.gis-view__background-label')
481
+ const editorAreaTransform = editorAreaLabel?.style.transform ?? ''
482
+ view.updateBackgroundEdit({ center: [editorStartState.center[0] + 10, editorStartState.center[1] + 6] })
483
+ const editorAreaSyncTransform = editorAreaLabel?.style.transform ?? ''
484
+ view.updateBackgroundEdit({ center: editorStartState.center })
485
+ await hoverFrame()
486
+ check('内置背景编辑:同一帧就把区域文字投到新锚点,不再比底图晚一帧',
487
+ Boolean(editorAreaTransform) && editorAreaSyncTransform !== editorAreaTransform,
488
+ `${editorAreaTransform || '(无)'} → ${editorAreaSyncTransform || '(无)'}`)
489
+ view.updateBackgroundEdit({ aspectLocked: false, rotation: true, style: { color: '#c2410c', dashed: false, handleSize: 22 } })
490
+ await hoverFrame()
491
+ const editorUnlockedState = view.getBackgroundEdit()
492
+ const editorStyledHandle = view.el.querySelector('[data-gis-background-handle=nw]')
493
+ const editorStyledHandleStyle = editorStyledHandle ? getComputedStyle(editorStyledHandle) : null
494
+ check('updateBackgroundEdit:解锁后八向加旋转点为九个编辑点,颜色 / 虚线 / 手柄尺寸即时生效',
495
+ editorUnlockedState?.aspectLocked === false && editorUnlockedState.rotationEnabled === true && editorUnlockedState.handleCount === 9 &&
496
+ visibleEditorHandles().length === 8 && visibleRotateHandles().length === 1 &&
497
+ editorLayer.style.getPropertyValue('--gis-view-bg-editor-color').trim().toLowerCase() === '#c2410c' &&
498
+ editorLayer.style.getPropertyValue('--gis-view-bg-editor-dash').trim() === 'none' &&
499
+ editorStyledHandleStyle?.width === '22px' && editorStyledHandleStyle?.borderRadius === '2px',
500
+ JSON.stringify({ state: editorUnlockedState, handles: visibleEditorHandles().length, rotate: visibleRotateHandles().length, width: editorStyledHandleStyle?.width, radius: editorStyledHandleStyle?.borderRadius }))
501
+ const apiRotateHandle = visibleRotateHandles()[0]
502
+ const apiRotateRect = apiRotateHandle?.getBoundingClientRect()
503
+ const apiRotateHit = apiRotateRect
504
+ ? document.elementFromPoint(apiRotateRect.left + apiRotateRect.width / 2, apiRotateRect.top + apiRotateRect.height / 2)
505
+ : null
506
+ check('内置背景编辑:旋转点能被真实指针命中,pointer-events 没有被编辑层挡住',
507
+ Boolean(apiRotateHandle && apiRotateHit && (apiRotateHit === apiRotateHandle || apiRotateHandle.contains(apiRotateHit)) && getComputedStyle(apiRotateHandle).pointerEvents === 'auto'),
508
+ JSON.stringify({ hit: apiRotateHit?.className ?? null, pointerEvents: apiRotateHandle ? getComputedStyle(apiRotateHandle).pointerEvents : null }))
509
+ if (apiRotateHandle && apiRotateRect && apiRotateHit) {
510
+ const centerClient = view.worldToClient(editorUnlockedState.center[0], editorUnlockedState.center[1])
511
+ const pointer = { bubbles: true, cancelable: true, composed: true, pointerId: 93, pointerType: 'mouse', isPrimary: true, button: 0, buttons: 1 }
512
+ const startX = apiRotateRect.left + apiRotateRect.width / 2
513
+ const startY = apiRotateRect.top + apiRotateRect.height / 2
514
+ apiRotateHit.dispatchEvent(new PointerEvent('pointerdown', { ...pointer, clientX: startX, clientY: startY }))
515
+ window.dispatchEvent(new PointerEvent('pointermove', { ...pointer, clientX: centerClient[0] + 140, clientY: centerClient[1] }))
516
+ window.dispatchEvent(new PointerEvent('pointerup', { ...pointer, button: 0, buttons: 0, clientX: centerClient[0] + 140, clientY: centerClient[1] }))
517
+ }
518
+ await hoverFrame()
519
+ const apiRotatedState = view.getBackgroundEdit()
520
+ const apiRotatedStyle = view.getOptions().background?.geoJsonStyle
521
+ const apiRotatedGeo = view.getResolvedBackground()?.geoJson
522
+ check('内置背景编辑:拖真实旋转点后 state / geoJsonStyle.rotation / 解析角度同步写回',
523
+ Math.abs(apiRotatedState.rotation - 90) < 1 && Math.abs(apiRotatedStyle.rotation - 90) < 1 &&
524
+ Math.abs(apiRotatedGeo.rotation - 90) < 1 && editorEndCount === 1 && editorChangeCount > 1,
525
+ JSON.stringify({ state: apiRotatedState.rotation, style: apiRotatedStyle?.rotation, resolved: apiRotatedGeo?.rotation, changes: editorChangeCount, ends: editorEndCount }))
526
+ const apiWidthBefore = apiRotatedState.width
527
+ view.updateBackgroundEdit({ width: apiWidthBefore + 2, angle: 15 })
528
+ await hoverFrame()
529
+ const apiPatchedState = view.getBackgroundEdit()
530
+ check('updateBackgroundEdit:宽高 / 角度可程序化写回,背景仍保留同一份 GeoJSON',
531
+ Math.abs(apiPatchedState.width - (apiWidthBefore + 2)) < 1e-6 && Math.abs(apiPatchedState.rotation - 15) < 1e-6 &&
532
+ view.getOptions().background?.geoJson?.type === 'FeatureCollection' && view.getOptions().background?.geoJsonStyle?.rotation === 15,
533
+ JSON.stringify({ width: apiPatchedState.width, rotation: apiPatchedState.rotation }))
534
+ const endEditorReturn = view.endBackgroundEdit()
535
+ await hoverFrame()
536
+ check('endBackgroundEdit:移除编辑层、读数回到 null 并恢复进入前的 backgroundEditing',
537
+ endEditorReturn === view && view.getBackgroundEdit() === null && view.getOptions().backgroundEditing === false &&
538
+ view.el.querySelector('[data-gis-background-editor]') === null && view.getData().points.length === 2 && view.getData().lines.length === 4,
539
+ JSON.stringify({ state: view.getBackgroundEdit(), editing: view.getOptions().backgroundEditing, layer: Boolean(view.el.querySelector('[data-gis-background-editor]')) }))
540
+ view.updateBackground(backgroundBeforeEditorApi)
541
+ view.setCamera(cameraBeforeEditorApi)
542
+ await hoverFrame()
543
+
246
544
  // 装饰跟着线路走:锚点长在路径上,几何一变就得跟到新位置,不能停在旧的
247
545
  const pointBox = (target, x, y, half = 15) => {
248
546
  const screen = target.project(x, y)
@@ -3291,6 +3589,7 @@ check('beginAddChannel({ lineData }):草稿按第二段对齐条数(circuits
3291
3589
  cmSend(casView, 'pointerup', screen, { buttons: 0, button: 0, ...extra })
3292
3590
  await settle(60)
3293
3591
  }
3592
+ const casWait = ms => new Promise(resolve => setTimeout(resolve, ms))
3294
3593
  await casClick(casView.project(0, 0))
3295
3594
  const casPointInfo = casView.getCascadeHighlight()
3296
3595
  check('级联高亮:点节点亮自身与直连线路 / 另一端节点(默认 both)',
@@ -3423,6 +3722,39 @@ check('beginAddChannel({ lineData }):草稿按第二段对齐条数(circuits
3423
3722
  casView.selectInBox({ x: casBoxD[0] - 30, y: casBoxD[1] - 30, width: 60, height: 60 }, { selectionKinds: ['point'] })
3424
3723
  check('triggerOnBoxSelect: false(缺省):框选只改选中集并收掉旧级联', casView.getCascadeHighlight() === null, JSON.stringify(casView.getCascadeHighlight()))
3425
3724
 
3725
+ // 取消选中时级联一起收掉:toggleSelect 的取消只发一条 null,接口取消也走同一口径
3726
+ casView.updateOptions({ toggleSelect: true, cascadeHighlight: { scope: 'both', triggerOnClick: true, triggerOnMultiSelect: true } })
3727
+ casView.selectFeatures(null)
3728
+ await settle(120)
3729
+ await casClick(casView.project(0, 0))
3730
+ await casWait(360)
3731
+ casEvents.length = 0
3732
+ casBusEvents.length = 0
3733
+ await casClick(casView.project(0, 0))
3734
+ check('toggleSelect 取消选中:级联同步收掉,且这一击只发一条 null',
3735
+ casView.getSelectionData().count === 0 && casView.getCascadeHighlight() === null && casEvents.length === 1 && casEvents[0] === null,
3736
+ JSON.stringify({ selected: casView.getSelectionData().count, cascade: casView.getCascadeHighlight(), events: casEvents }))
3737
+
3738
+ await casWait(360)
3739
+ await casClick(casView.project(0, 0))
3740
+ casEvents.length = 0
3741
+ casBusEvents.length = 0
3742
+ casView.deselect('cas-a')
3743
+ await settle(60)
3744
+ check('deselect 取消级联来源:级联同步收掉并只发一条 null',
3745
+ casView.getSelectionData().count === 0 && casView.getCascadeHighlight() === null && casEvents.length === 1 && casEvents[0] === null,
3746
+ JSON.stringify({ selected: casView.getSelectionData().count, cascade: casView.getCascadeHighlight(), events: casEvents }))
3747
+
3748
+ await casWait(360)
3749
+ await casClick(casView.project(0, 0))
3750
+ casEvents.length = 0
3751
+ casBusEvents.length = 0
3752
+ casView.selectFeatures('cas-a', { mode: 'remove' })
3753
+ await settle(60)
3754
+ check('selectFeatures remove 取消级联来源:级联同步收掉并只发一条 null',
3755
+ casView.getSelectionData().count === 0 && casView.getCascadeHighlight() === null && casEvents.length === 1 && casEvents[0] === null,
3756
+ JSON.stringify({ selected: casView.getSelectionData().count, cascade: casView.getCascadeHighlight(), events: casEvents }))
3757
+
3426
3758
  casEvents.length = 0
3427
3759
  casView.updateOptions({ cascadeHighlight: false })
3428
3760
  await settle(60)
@@ -4383,8 +4715,10 @@ check('beginAddChannel({ lineData }):草稿按第二段对齐条数(circuits
4383
4715
  const pluginLog = []
4384
4716
  const pluginWarnings = []
4385
4717
  const pluginErrors = []
4386
- let optionPluginInstance = null
4718
+ let optionPluginInstance = null
4387
4719
  let optionPluginContext = null
4720
+ let optionSignal = null
4721
+ let optionCleanupCount = 0
4388
4722
  const pluginView = createGisView(pluginHost, {
4389
4723
  points: [{ id: 'pg-1', x: 0, y: 0 }],
4390
4724
  lines: [],
@@ -4395,10 +4729,13 @@ check('beginAddChannel({ lineData }):草稿按第二段对齐条数(circuits
4395
4729
  plugins: [(v, context) => {
4396
4730
  optionPluginInstance = v
4397
4731
  optionPluginContext = context
4732
+ optionSignal = context.signal
4733
+ context.onCleanup(() => { optionCleanupCount += 1 })
4398
4734
  return { destroy: () => pluginLog.push('options') }
4399
4735
  }]
4400
4736
  })
4401
4737
  check('options.plugins 拿到实例本身与带 apiVersion 的只读上下文', optionPluginInstance === pluginView && optionPluginContext?.apiVersion === 1 && optionPluginContext.view === pluginView && Object.isFrozen(optionPluginContext), JSON.stringify(optionPluginContext ? { apiVersion: optionPluginContext.apiVersion, sameView: optionPluginContext.view === pluginView } : null))
4738
+ check('插件上下文带独立 signal,创建期间未中止', optionSignal instanceof AbortSignal && optionSignal.aborted === false && optionSignal !== null, String(optionSignal?.aborted))
4402
4739
  let usePluginInstance = null
4403
4740
  let usePluginContext = null
4404
4741
  let useDestroyCount = 0
@@ -4408,6 +4745,32 @@ check('beginAddChannel({ lineData }):草稿按第二段对齐条数(circuits
4408
4745
  return { destroy: () => { useDestroyCount += 1 } }
4409
4746
  })
4410
4747
  check('use() 立即执行,实例与带版本的上下文都和 options.plugins 一致', usePluginInstance === pluginView && usePluginContext?.apiVersion === 1 && usePluginContext.view === pluginView && Object.isFrozen(usePluginContext))
4748
+ check('每个插件拿到自己的 signal(不共用同一个控制器)', usePluginContext?.signal instanceof AbortSignal && usePluginContext.signal.aborted === false && usePluginContext.signal !== optionSignal)
4749
+ // 插件作用域的自动清理:命令 / 事件 / onCleanup 都由同一个卸载函数收回;
4750
+ // onCleanup 提前执行过的项不会在卸载时再跑,多个清理项按登记的倒序执行
4751
+ const lifecycleLog = []
4752
+ let lifecycleSignal = null
4753
+ let lifecycleEvents = 0
4754
+ const disposeLifecycle = pluginView.use((v, context) => {
4755
+ lifecycleSignal = context.signal
4756
+ const early = context.onCleanup(() => lifecycleLog.push('early'))
4757
+ early()
4758
+ early()
4759
+ context.onCleanup(() => lifecycleLog.push('first'))
4760
+ context.onCleanup(() => lifecycleLog.push('second'))
4761
+ context.on('dataChange', () => { lifecycleEvents += 1 })
4762
+ context.registerCommand('pluginLifecycle', payload => `lifecycle:${payload}`)
4763
+ return { destroy: () => lifecycleLog.push('destroy') }
4764
+ })
4765
+ const lifecycleCommitted = pluginView.executeCommand('pluginLifecycle', 'ok')
4766
+ pluginView.updatePoint('pg-1', { name: '插件事件一' })
4767
+ check('插件 context.on / registerCommand 已接通:事件与命令都能用', lifecycleCommitted.ok === true && lifecycleCommitted.value === 'lifecycle:ok' && lifecycleEvents === 1, JSON.stringify({ command: lifecycleCommitted, events: lifecycleEvents }))
4768
+ disposeLifecycle()
4769
+ disposeLifecycle()
4770
+ const lifecycleCommandAfterDispose = pluginView.listCommands().includes('pluginLifecycle')
4771
+ pluginView.updatePoint('pg-1', { name: '插件事件二' })
4772
+ check('插件卸载:signal 中止、事件自动退订、命令自动注销', lifecycleSignal?.aborted === true && lifecycleCommandAfterDispose === false && lifecycleEvents === 1, JSON.stringify({ aborted: lifecycleSignal?.aborted, command: lifecycleCommandAfterDispose, events: lifecycleEvents }))
4773
+ check('插件卸载:destroy 先跑,onCleanup 按登记倒序跑,提前执行的项不重复', lifecycleLog.join(',') === 'early,destroy,second,first', lifecycleLog.join(','))
4411
4774
  disposeUse()
4412
4775
  disposeUse()
4413
4776
  check('use() 返回的卸载函数跑插件的 destroy(重复调用只跑一次)', useDestroyCount === 1, String(useDestroyCount))
@@ -4417,8 +4780,15 @@ check('beginAddChannel({ lineData }):草稿按第二段对齐条数(circuits
4417
4780
  const beforeWarn = pluginWarnings.length
4418
4781
  pluginView.use(null)
4419
4782
  check('use() 收到非函数只提醒、不抛错', pluginWarnings.length === beforeWarn + 1 && /插件函数/.test(pluginWarnings.at(-1)), pluginWarnings.at(-1))
4420
- pluginView.use(() => { throw new Error('插件里抛的错') })
4783
+ let failedPluginSignal = null
4784
+ let failedPluginCleanups = 0
4785
+ pluginView.use((v, context) => {
4786
+ failedPluginSignal = context.signal
4787
+ context.onCleanup(() => { failedPluginCleanups += 1 })
4788
+ throw new Error('插件里抛的错')
4789
+ })
4421
4790
  check('插件初始化抛错走 onError、不打断其它插件', pluginErrors.some(text => text.includes('插件初始化失败')), pluginErrors.at(-1))
4791
+ check('插件初始化抛错也会中止 signal 并清理已登记资源', failedPluginSignal?.aborted === true && failedPluginCleanups === 1, JSON.stringify({ aborted: failedPluginSignal?.aborted, cleanups: failedPluginCleanups }))
4422
4792
  let destroyOrder = []
4423
4793
  const orderHost = document.createElement('div')
4424
4794
  document.querySelector('#map').append(orderHost)
@@ -4432,9 +4802,26 @@ check('beginAddChannel({ lineData }):草稿按第二段对齐条数(circuits
4432
4802
  check('销毁时按挂载的相反顺序清理(use 挂得晚、先清)', destroyOrder.join(',') === 'use,options', destroyOrder.join(','))
4433
4803
  orderView.use(() => { throw new Error('destroy 之后不该执行') })
4434
4804
  check('destroy 之后 use() 不再执行插件(返回的卸载函数也安全)', true)
4805
+ // 插件初始化时可能只保存 context,之后再按用户操作登记资源:
4806
+ // 成功初始化就必须进入视图生命周期,不能因为「挂载那一刻没有清理项」漏掉 destroy 时的回收
4807
+ const lateHost = document.createElement('div')
4808
+ document.querySelector('#map').append(lateHost)
4809
+ const lateView = createGisView(lateHost, {
4810
+ points: [{ id: 'late-1', x: 0, y: 0 }],
4811
+ width: 200,
4812
+ height: 160
4813
+ })
4814
+ let lateContext = null
4815
+ let lateCleanups = 0
4816
+ lateView.use((v, context) => { lateContext = context })
4817
+ lateContext.onCleanup(() => { lateCleanups += 1 })
4818
+ lateView.destroy()
4819
+ check('插件初始化后再登记的资源仍随实例销毁清理(signal 中止 / onCleanup 执行)', lateContext?.signal.aborted === true && lateCleanups === 1, JSON.stringify({ aborted: lateContext?.signal.aborted, cleanups: lateCleanups }))
4820
+ lateHost.remove()
4435
4821
  pluginView.destroy()
4436
4822
  check('options 插件随实例销毁一起清理', pluginLog.join(',') === 'options', pluginLog.join(','))
4437
4823
  check('use 挂的插件在实例销毁时也已清理(卸载函数不会二次回调)', useDestroyCount === 1, String(useDestroyCount))
4824
+ check('实例 destroy 会中止 options 插件的 signal 并执行自动清理', optionSignal?.aborted === true && optionCleanupCount === 1, JSON.stringify({ aborted: optionSignal?.aborted, cleanups: optionCleanupCount }))
4438
4825
  pluginHost.remove()
4439
4826
  orderHost.remove()
4440
4827
  }
@@ -5994,6 +6381,128 @@ check('beginAddChannel({ lineData }):草稿按第二段对齐条数(circuits
5994
6381
  check('hideFeatures(channel):visible 落数据、通道退场,变化账本记 visibility', hideChannel.ok === true && splitView.getOptions().channels.find(channel => channel.id === 'sv-ch')?.visible === false && hiddenChannelRead?.lineIndexes?.length === 0 && hideChanged?.visibilityChanged === true, JSON.stringify({ ok: hideChannel.ok, paths: hiddenChannelRead?.lineIndexes?.length, reasons: hideChanged?.reasons }))
5995
6382
  const showChannel = splitView.showFeatures('channel', 'sv-ch')
5996
6383
  check('showFeatures(channel):隐藏后原样恢复(回线、分组、拐点都还在)', showChannel.ok === true && splitView.getChannel('sv-ch')?.lineIndexes?.length === 2 && splitView.getOptions().channels.find(channel => channel.id === 'sv-ch')?.visible === true && splitView.getChannelGroups('sv-ch').length === 1, JSON.stringify({ lines: splitView.getChannel('sv-ch')?.lineIndexes?.length, groups: splitView.getChannelGroups('sv-ch').length }))
6384
+
6385
+ /* ---------- 本轮:单个 / 批量隐藏通道分组,隐藏组的成员归属仍可查 ---------- */
6386
+ const groupHost = document.createElement('div')
6387
+ groupHost.style.cssText = 'width:560px;height:320px'
6388
+ document.body.append(groupHost)
6389
+ const groupChanges = []
6390
+ const groupView = createGisView(groupHost, {
6391
+ points: [
6392
+ { id: 'gv-a', x: 0, y: 0 },
6393
+ { id: 'gv-b', x: 100, y: 0 }
6394
+ ],
6395
+ lines: [
6396
+ { id: 'gv-l1', from: 'gv-a', to: 'gv-b', name: 'G1-A' },
6397
+ { id: 'gv-l2', from: 'gv-a', to: 'gv-b', name: 'G1-B' },
6398
+ { id: 'gv-l3', from: 'gv-a', to: 'gv-b', name: 'G2-A' }
6399
+ ],
6400
+ channels: [{
6401
+ id: 'gv-ch',
6402
+ from: 'gv-a',
6403
+ to: 'gv-b',
6404
+ width: 40,
6405
+ groups: [
6406
+ { id: 'gv-g1', lineIds: ['gv-l1', 'gv-l2'] },
6407
+ { id: 'gv-g2', lineIds: ['gv-l3'] }
6408
+ ]
6409
+ }],
6410
+ initialView: { scale: 1, center: [50, 0] },
6411
+ labelOptimize: false,
6412
+ showGrid: false,
6413
+ onDataChange: payload => groupChanges.push(payload)
6414
+ })
6415
+ await settle(100)
6416
+ const hiddenGroup = groupView.hideFeatures('group', { channelId: 'gv-ch', groupId: 'gv-g1' })
6417
+ const hiddenGroupRead = groupView.getChannelGroups('gv-ch').find(group => group.id === 'gv-g1')
6418
+ const hiddenGroupLines = groupView.listLines().map(line => line.item.id)
6419
+ const hiddenGroupMembership = groupView.getLineChannel('gv-l1')
6420
+ check('hideFeatures(group):只把这一组的 visible 落数据,隐藏组读数仍保留成员与骨架',
6421
+ hiddenGroup.ok === true && hiddenGroup.ids[0] === 'gv-g1' && hiddenGroup.groups[0]?.visible === false && hiddenGroupRead?.visible === false && hiddenGroupRead.lineIds.length === 2 && groupView.getData().lines.length === 3,
6422
+ JSON.stringify({ ids: hiddenGroup.ids, read: hiddenGroupRead && { visible: hiddenGroupRead.visible, lineIds: hiddenGroupRead.lineIds } }))
6423
+ check('hideFeatures(group):隐藏组成员从画面退场,其他分组不受影响;getLineChannel 仍能查回隐藏组',
6424
+ !hiddenGroupLines.includes('gv-l1') && !hiddenGroupLines.includes('gv-l2') && hiddenGroupLines.includes('gv-l3') && hiddenGroupMembership?.groupId === 'gv-g1' && hiddenGroupMembership?.group?.visible === false,
6425
+ JSON.stringify({ lines: hiddenGroupLines, membership: hiddenGroupMembership?.groupId }))
6426
+ const shownGroup = groupView.showFeatures('group', 'gv-g1')
6427
+ check('showFeatures(group):唯一分组 id 可直接恢复,成员原样回来',
6428
+ shownGroup.ok === true && shownGroup.groups[0]?.visible === true && groupView.listLines().some(line => line.item.id === 'gv-l1') && groupView.getOptions().channels[0].groups.every(group => group.visible !== false),
6429
+ JSON.stringify({ groups: shownGroup.groups.map(group => ({ id: group.id, visible: group.visible })) }))
6430
+ const changesBeforeGroupBatch = groupChanges.length
6431
+ const batchHiddenGroups = groupView.hideFeatures('group', [
6432
+ { channelId: 'gv-ch', groupId: 'gv-g1' },
6433
+ { channelIndex: 0, groupIndex: 1 }
6434
+ ])
6435
+ check('hideFeatures(group):一次批量隐藏多组,结果按分组返回且只有一条 batch 数据变更',
6436
+ batchHiddenGroups.ok === true && batchHiddenGroups.groups.length === 2 && batchHiddenGroups.groups.every(group => group.visible === false) && groupChanges.length === changesBeforeGroupBatch + 1 && groupChanges.at(-1)?.type === 'batch',
6437
+ JSON.stringify({ groups: batchHiddenGroups.groups.map(group => group.visible), events: groupChanges.slice(changesBeforeGroupBatch).map(payload => payload.type) }))
6438
+ const batchShownGroups = groupView.showFeatures('group', [
6439
+ { channelId: 'gv-ch', groupIndex: 0 },
6440
+ { channelId: 'gv-ch', groupIndex: 1 }
6441
+ ])
6442
+ check('showFeatures(group):按通道 + groupIndex 批量恢复', batchShownGroups.ok === true && batchShownGroups.groups.every(group => group.visible === true) && groupView.listLines().length === 3, JSON.stringify(batchShownGroups.groups.map(group => group.visible)))
6443
+ groupView.selectFeatures('gv-l1')
6444
+ const selectionGroup = groupView.hideFeatures('group', 'selection')
6445
+ check('hideFeatures(group, selection):按选中回线推导所属分组,只隐藏这一组',
6446
+ selectionGroup.ok === true && selectionGroup.ids[0] === 'gv-g1' && groupView.getChannelGroups('gv-ch').find(group => group.id === 'gv-g1')?.visible === false && groupView.getChannelGroups('gv-ch').find(group => group.id === 'gv-g2')?.visible === true,
6447
+ JSON.stringify(selectionGroup.groups.map(group => ({ id: group.id, visible: group.visible }))))
6448
+ groupView.showFeatures('group', 'gv-g1')
6449
+ const batchHiddenLines = groupView.hideFeatures('line', ['gv-l1', 'gv-l2'])
6450
+ const batchHiddenPoints = groupView.hideFeatures('point', ['gv-a', 'gv-b'])
6451
+ const batchHiddenChannel = groupView.hideFeatures('channel', ['gv-ch'])
6452
+ check('hideFeatures:节点 / 线路 / 通道 / 分组都支持一批引用,并返回统一的 groups 字段',
6453
+ batchHiddenLines.ok === true && batchHiddenLines.indexes.length === 2 && batchHiddenPoints.ok === true && batchHiddenPoints.indexes.length === 2 && batchHiddenChannel.ok === true && Array.isArray(batchHiddenLines.groups) && Array.isArray(batchHiddenPoints.groups) && Array.isArray(batchHiddenChannel.groups),
6454
+ JSON.stringify({ lines: batchHiddenLines.indexes, points: batchHiddenPoints.indexes, channel: batchHiddenChannel.indexes }))
6455
+ groupView.showFeatures('line', ['gv-l1', 'gv-l2'])
6456
+ groupView.showFeatures('point', ['gv-a', 'gv-b'])
6457
+ groupView.showFeatures('channel', ['gv-ch'])
6458
+ check('showFeatures:批量恢复节点、线路、通道后分组与成员都回来',
6459
+ groupView.listLines().length === 3 && groupView.getChannels().filter(channel => channel.channelId === 'gv-ch').reduce((count, channel) => count + channel.lineIndexes.length, 0) === 3 && groupView.getChannelGroups('gv-ch').length === 2,
6460
+ JSON.stringify({ lines: groupView.listLines().map(line => line.item.id), channelLines: groupView.getChannels().filter(channel => channel.channelId === 'gv-ch').map(channel => channel.lineIndexes), groups: groupView.getChannelGroups('gv-ch').map(group => group.id) }))
6461
+
6462
+ /* ---------- 本轮:隐藏回线后的车道排布 preserve / center ---------- */
6463
+ const layoutHost = document.createElement('div')
6464
+ layoutHost.style.cssText = 'width:520px;height:300px'
6465
+ document.body.append(layoutHost)
6466
+ const layoutView = createGisView(layoutHost, {
6467
+ points: [
6468
+ { id: 'lv-a', x: 0, y: 0 },
6469
+ { id: 'lv-b', x: 120, y: 0 }
6470
+ ],
6471
+ lines: [
6472
+ { id: 'lv-l1', from: 'lv-a', to: 'lv-b', width: 5 },
6473
+ { id: 'lv-l2', from: 'lv-a', to: 'lv-b', width: 5 }
6474
+ ],
6475
+ channels: [{ id: 'lv-ch', from: 'lv-a', to: 'lv-b', lineIds: ['lv-l1', 'lv-l2'], width: 44 }],
6476
+ channelHiddenLineLayout: 'preserve',
6477
+ initialView: { scale: 1, center: [60, 0] },
6478
+ labelOptimize: false,
6479
+ showGrid: false
6480
+ })
6481
+ await settle(100)
6482
+ const layoutPath = id => layoutView.listLines().find(line => line.item.id === id)?.path ?? null
6483
+ const preserveBefore = layoutPath('lv-l2')
6484
+ layoutView.hideFeatures('line', 'lv-l1')
6485
+ const preserveHidden = layoutPath('lv-l2')
6486
+ check('channelHiddenLineLayout: preserve(缺省)隐藏一条回线时,另一条保持原车道',
6487
+ Boolean(preserveBefore && preserveHidden) && preserveHidden[0][1] === preserveBefore[0][1] && Math.abs(preserveHidden[0][1]) > 0.5,
6488
+ JSON.stringify({ before: preserveBefore?.[0], hidden: preserveHidden?.[0] }))
6489
+ layoutView.updateOptions({ channelHiddenLineLayout: 'center' })
6490
+ const centeredHidden = layoutPath('lv-l2')
6491
+ check('channelHiddenLineLayout: center 隐藏一条回线后,剩余回线回到通道中心',
6492
+ Boolean(centeredHidden) && Math.abs(centeredHidden[0][1]) < 1e-6,
6493
+ JSON.stringify(centeredHidden?.[0]))
6494
+ layoutView.showFeatures('line', 'lv-l1')
6495
+ layoutView.hideFeatures('line', 'lv-l2')
6496
+ const centeredFromRight = layoutPath('lv-l1')
6497
+ check('channelHiddenLineLayout: center 对另一条回线同样居中', Boolean(centeredFromRight) && Math.abs(centeredFromRight[0][1]) < 1e-6, JSON.stringify({ path: centeredFromRight?.[0], hidden: layoutView.getOptions().lines.find(line => line.id === 'lv-l2')?.visible, visibleLines: layoutView.listLines().map(line => line.item.id) }))
6498
+ layoutView.showFeatures('line', 'lv-l2')
6499
+ layoutView.updateOptions({ channelHiddenLineLayout: 'preserve' })
6500
+ const preserveRestored = layoutPath('lv-l1')
6501
+ check('channelHiddenLineLayout: preserve 恢复后回线仍有独立车道', Boolean(preserveRestored) && Math.abs(preserveRestored[0][1]) > 0.5, JSON.stringify(preserveRestored?.[0]))
6502
+ groupView.destroy()
6503
+ groupHost.remove()
6504
+ layoutView.destroy()
6505
+ layoutHost.remove()
5997
6506
  const generatedId = splitView.getData().points.find(point => point.name === 'A-右')?.id
5998
6507
  const hidePoint = splitView.hideFeatures('point', generatedId)
5999
6508
  const hideLine = splitView.hideFeatures('line', 'sv-ac')
@@ -6137,6 +6646,923 @@ check('beginAddChannel({ lineData }):草稿按第二段对齐条数(circuits
6137
6646
  check('destroy 后清除 idle 定时器,不再触发回调', idleCalls.length === idleBeforeDestroyWait, JSON.stringify({ before: idleBeforeDestroyWait, after: idleCalls.length }))
6138
6647
  idleHost.remove()
6139
6648
 
6649
+ // 样式规则优先级:普通规则 > pointStyle / lineStyle,要素字段默认更高,override: true 才压过要素;
6650
+ // 状态样式始终最高。直接调解析函数验证字段级回落,再用实例方法验证单条改样式与动态换规则。
6651
+ const sameRgba = (actual, expected) => {
6652
+ const target = parseColor(expected)
6653
+ return Array.isArray(actual) && actual.length === 4 && actual.every((part, index) => Math.abs(part - target[index]) < 1e-4)
6654
+ }
6655
+ const ruleSymbolStore = {
6656
+ resolve: (input, color, size, strokeColor, strokeWidth) => ({ input, color, size, strokeColor, strokeWidth, width: size, height: size })
6657
+ }
6658
+ const rulePointDefaults = { color: '#111111', size: 10, strokeColor: '#eeeeee', strokeWidth: 1, opacity: 1, zIndex: 0 }
6659
+ const ruleLineDefaults = { color: '#111111', width: 1, opacity: 1 }
6660
+ const pointWhen = { field: 'id', op: 'eq', value: 'priority-edge' }
6661
+ const normalPointRules = compilePointRules([
6662
+ { when: pointWhen, style: { color: '#3366ff', size: 24, strokeColor: '#2244cc', opacity: 0.45, zIndex: 4, symbol: undefined } },
6663
+ { when: pointWhen, style: { color: '#00aa88', size: 26 } }
6664
+ ])
6665
+ const pointRuleOptions = { rules: normalPointRules, ruleScratch: {}, ruleOverrideScratch: {} }
6666
+ const pointFromStyle = resolvePoint({ id: 'priority-edge', x: 0, y: 0 }, 0, { color: '#00ff00', size: 12, opacity: 0.9, zIndex: 1 }, rulePointDefaults, ruleSymbolStore, undefined, pointRuleOptions)
6667
+ check('普通点规则高于 pointStyle:没写的字段取规则,后命中的同层规则覆盖前面',
6668
+ sameRgba(pointFromStyle.color, '#00aa88') && pointFromStyle.styleSize === 26 && pointFromStyle.opacity === 0.45 && sameRgba(pointFromStyle.strokeColor, '#2244cc') && pointFromStyle.zIndex === 4,
6669
+ JSON.stringify({ color: pointFromStyle.color, size: pointFromStyle.styleSize, opacity: pointFromStyle.opacity, strokeColor: pointFromStyle.strokeColor, zIndex: pointFromStyle.zIndex }))
6670
+ const ownPoint = { id: 'priority-edge', x: 0, y: 0, color: '#ff0000', size: 18, symbol: 'square' }
6671
+ const pointOwnStyle = resolvePoint(ownPoint, 0, { color: '#00ff00', size: 12, opacity: 0.9, zIndex: 1 }, rulePointDefaults, ruleSymbolStore, undefined, pointRuleOptions)
6672
+ check('普通点规则低于要素自身字段:逐点的 color / size / symbol 不被批量规则改掉,缺的字段仍由规则补齐',
6673
+ sameRgba(pointOwnStyle.color, '#ff0000') && pointOwnStyle.styleSize === 18 && pointOwnStyle.styleSymbol === 'square' && pointOwnStyle.opacity === 0.45 && pointOwnStyle.zIndex === 4,
6674
+ JSON.stringify({ color: pointOwnStyle.color, size: pointOwnStyle.styleSize, symbol: pointOwnStyle.styleSymbol, opacity: pointOwnStyle.opacity, zIndex: pointOwnStyle.zIndex }))
6675
+ check('规则编译期只记真正写了的字段', normalPointRules[0].fields.length === 5 && ['color', 'size', 'strokeColor', 'opacity', 'zIndex'].every(key => normalPointRules[0].fields.includes(key)) && !normalPointRules[0].fields.includes('symbol'), JSON.stringify(normalPointRules[0].fields))
6676
+
6677
+ const overridePointRules = compilePointRules([
6678
+ { when: pointWhen, style: { strokeWidth: 3, opacity: 0.35 } },
6679
+ { when: pointWhen, override: true, style: { color: '#0033ff', size: 30 } },
6680
+ { when: pointWhen, override: true, style: { color: '#9900ff', size: 32 } }
6681
+ ])
6682
+ const overridePointOptions = { rules: overridePointRules, ruleScratch: {}, ruleOverrideScratch: {} }
6683
+ const mixedPoint = { id: 'priority-edge', x: 0, y: 0, color: '#ff0000', size: 18, symbol: 'circle' }
6684
+ const overriddenPoint = resolvePoint(mixedPoint, 0, { color: '#00ff00', size: 12, zIndex: 1 }, rulePointDefaults, ruleSymbolStore, undefined, overridePointOptions)
6685
+ check('override 点规则压过要素字段,未写的字段继续取要素 / 普通规则;override 层后命中的赢',
6686
+ sameRgba(overriddenPoint.color, '#9900ff') && overriddenPoint.styleSize === 32 && overriddenPoint.styleSymbol === 'circle' && overriddenPoint.symbol.strokeWidth === 3 && overriddenPoint.opacity === 0.35,
6687
+ JSON.stringify({ color: overriddenPoint.color, size: overriddenPoint.styleSize, symbol: overriddenPoint.styleSymbol, strokeWidth: overriddenPoint.symbol.strokeWidth, opacity: overriddenPoint.opacity }))
6688
+ const statedPoint = resolvePoint(mixedPoint, 0, { color: '#00ff00', size: 12, zIndex: 1 }, rulePointDefaults, ruleSymbolStore, { color: '#222222', size: 12, symbol: 'triangle', opacity: 0.8 }, overridePointOptions)
6689
+ check('状态样式仍高于 override 点规则', sameRgba(statedPoint.color, '#222222') && statedPoint.size === 12 && statedPoint.symbol.input === 'triangle' && statedPoint.opacity === 0.8, JSON.stringify({ color: statedPoint.color, size: statedPoint.size, symbol: statedPoint.symbol.input, opacity: statedPoint.opacity }))
6690
+
6691
+ const lineWhen = { field: 'id', op: 'eq', value: 'priority-line' }
6692
+ const lineStyleBase = { color: '#00ff00', width: 2, opacity: 0.9, zIndex: 1 }
6693
+ const normalLineRules = compileLineRules([
6694
+ { when: lineWhen, style: { color: '#3366ff', width: 4.5, opacity: 0.35, lineType: 'dashed' } },
6695
+ { when: lineWhen, style: { color: '#00aa88', width: 5 } }
6696
+ ])
6697
+ const lineRuleOptions = { rules: normalLineRules, ruleScratch: {}, ruleOverrideScratch: {} }
6698
+ const lineFromStyle = resolveLineStyle({ id: 'priority-line' }, 0, lineStyleBase, ruleLineDefaults, null, undefined, lineRuleOptions)
6699
+ const ownLine = { id: 'priority-line', color: '#ff0000', width: 8, opacity: 0.8, lineType: 'dotted', zIndex: 2 }
6700
+ const ownLineStyle = resolveLineStyle(ownLine, 0, lineStyleBase, ruleLineDefaults, null, undefined, lineRuleOptions)
6701
+ check('普通线规则高于 lineStyle 且低于线路自身字段', sameRgba(lineFromStyle.color, '#00aa88') && lineFromStyle.baseWidth === 5 && lineFromStyle.opacity === 0.35 && Boolean(lineFromStyle.dash) && sameRgba(ownLineStyle.color, '#ff0000') && ownLineStyle.baseWidth === 8 && ownLineStyle.zIndex === 2 && Boolean(ownLineStyle.dash), JSON.stringify({ fromStyle: { color: lineFromStyle.color, width: lineFromStyle.baseWidth, opacity: lineFromStyle.opacity, dashed: Boolean(lineFromStyle.dash) }, own: { color: ownLineStyle.color, width: ownLineStyle.baseWidth, zIndex: ownLineStyle.zIndex, dashed: Boolean(ownLineStyle.dash) } }))
6702
+
6703
+ const overrideLineRules = compileLineRules([
6704
+ { when: lineWhen, style: { opacity: 0.4 } },
6705
+ { when: lineWhen, override: true, style: { color: '#0033ff', width: 6 } },
6706
+ { when: lineWhen, override: true, style: { color: '#9900ff', width: 7, zIndex: 6 } }
6707
+ ])
6708
+ const overrideLineOptions = { rules: overrideLineRules, ruleScratch: {}, ruleOverrideScratch: {} }
6709
+ const overriddenLine = resolveLineStyle({ id: 'priority-line', color: '#ff0000', width: 8, lineType: 'dotted' }, 0, lineStyleBase, ruleLineDefaults, null, undefined, overrideLineOptions)
6710
+ const statedLine = resolveLineStyle({ id: 'priority-line', color: '#ff0000', width: 8, lineType: 'dotted' }, 0, lineStyleBase, ruleLineDefaults, null, { color: '#222222', width: 3, opacity: 0.8 }, overrideLineOptions)
6711
+ check('override 线规则压过要素字段,未写字段回落普通规则;状态样式仍最高', sameRgba(overriddenLine.color, '#9900ff') && overriddenLine.baseWidth === 7 && overriddenLine.opacity === 0.4 && overriddenLine.zIndex === 6 && Boolean(overriddenLine.dash) && sameRgba(statedLine.color, '#222222') && statedLine.halfWidth === 1.5 && statedLine.opacity === 0.8, JSON.stringify({ override: { color: overriddenLine.color, width: overriddenLine.baseWidth, opacity: overriddenLine.opacity, zIndex: overriddenLine.zIndex, dashed: Boolean(overriddenLine.dash) }, state: { color: statedLine.color, baseWidth: statedLine.baseWidth, halfWidth: statedLine.halfWidth, opacity: statedLine.opacity } }))
6712
+
6713
+ const rulePriorityHost = document.createElement('div')
6714
+ rulePriorityHost.style.cssText = 'width:480px;height:280px'
6715
+ document.body.append(rulePriorityHost)
6716
+ const rulePriorityView = createGisView(rulePriorityHost, {
6717
+ points: [
6718
+ { id: 'rp-normal', x: 0, y: 0 },
6719
+ { id: 'rp-own', x: 60, y: 0, color: '#ff0000', size: 18, symbol: 'circle' }
6720
+ ],
6721
+ lines: [{ id: 'rp-line', path: [[0, 40], [60, 40]], color: '#10b981', width: 6, lineType: 'dotted' }],
6722
+ pointStyle: { color: '#00ff00', size: 12 },
6723
+ lineStyle: { color: '#00ff00', width: 1, opacity: 0.9 },
6724
+ pointRules: [
6725
+ { when: { id: 'rp-normal' }, style: { color: '#00aa88', size: 22 } },
6726
+ { when: { id: 'rp-own' }, style: { color: '#00aa88', size: 22 } }
6727
+ ],
6728
+ lineRules: [{ when: { id: 'rp-line' }, style: { color: '#00aa88', width: 3, opacity: 0.5 } }],
6729
+ showGrid: false
6730
+ })
6731
+ const rulePointIndex = id => rulePriorityView.getData().points.findIndex(point => point.id === id)
6732
+ const ruleLineIndex = id => rulePriorityView.getData().lines.findIndex(line => line.id === id)
6733
+ check('主视图快照:普通规则补 pointStyle,但逐点字段仍优先', rulePriorityView.getPointStyle(rulePointIndex('rp-normal'))?.size === 22 && rulePriorityView.getPointStyle(rulePointIndex('rp-own'))?.size === 18 && rulePriorityView.getLineStyle(ruleLineIndex('rp-line'))?.width === 6, JSON.stringify({ normal: rulePriorityView.getPointStyle(rulePointIndex('rp-normal')), own: rulePriorityView.getPointStyle(rulePointIndex('rp-own')), line: rulePriorityView.getLineStyle(ruleLineIndex('rp-line')) }))
6734
+ rulePriorityView.updatePoint('rp-own', { color: '#ffcc00', size: 27, symbol: { type: 'square', radius: 2 } })
6735
+ const updatedPointStyle = rulePriorityView.getPointStyle(rulePointIndex('rp-own'))
6736
+ check('updatePoint:单个节点可改颜色、尺寸与图标,普通规则不把它盖回去', rulePriorityView.getData().points[rulePointIndex('rp-own')]?.color === '#ffcc00' && updatedPointStyle?.size === 27 && updatedPointStyle?.symbol?.type === 'square' && updatedPointStyle?.symbol?.radius === 2, JSON.stringify({ item: rulePriorityView.getData().points[rulePointIndex('rp-own')], style: updatedPointStyle }))
6737
+ rulePriorityView.updateOptions({ pointRules: [{ when: { id: 'rp-own' }, override: true, style: { color: '#0033ff', size: 30 } }] })
6738
+ const overrideRulePointStyle = rulePriorityView.getPointStyle(rulePointIndex('rp-own'))
6739
+ rulePriorityView.updateOptions({ pointRules: [{ when: { id: 'rp-own' }, style: { color: '#00aa88', size: 22 } }] })
6740
+ check('updateOptions 切换 override:规则可压过单点样式,改回普通规则后逐点值恢复', overrideRulePointStyle?.size === 30 && rulePriorityView.getPointStyle(rulePointIndex('rp-own'))?.size === 27, JSON.stringify({ override: overrideRulePointStyle, normal: rulePriorityView.getPointStyle(rulePointIndex('rp-own')) }))
6741
+ rulePriorityView.updateLine('rp-line', { color: '#ef4444', width: 7, opacity: 0.25, lineType: 'dashed', zIndex: 3 })
6742
+ const updatedLineStyle = rulePriorityView.getLineStyle(ruleLineIndex('rp-line'))
6743
+ rulePriorityView.updateOptions({ lineRules: [{ when: { id: 'rp-line' }, override: true, style: { color: '#0033ff', width: 2 } }] })
6744
+ const overrideRuleLineStyle = rulePriorityView.getLineStyle(ruleLineIndex('rp-line'))
6745
+ check('updateLine / updateOptions:单条线路改色宽透明度与线型,override 规则再压过它', sameRgba(updatedLineStyle?.color, '#ef4444') && updatedLineStyle?.width === 7 && updatedLineStyle?.opacity === 0.25 && updatedLineStyle?.zIndex === 3 && sameRgba(overrideRuleLineStyle?.color, '#0033ff') && overrideRuleLineStyle?.width === 2 && overrideRuleLineStyle?.opacity === 0.25, JSON.stringify({ updated: updatedLineStyle, override: overrideRuleLineStyle }))
6746
+ rulePriorityView.destroy()
6747
+ rulePriorityHost.remove()
6748
+
6749
+ // 交互样式优先级:单选 / 多选 / 框选 / 级联是独立的状态层,override 规则也不能改它们写的颜色
6750
+ const interactionPriorityHost = document.createElement('div')
6751
+ interactionPriorityHost.style.cssText = 'width:520px;height:320px'
6752
+ document.body.append(interactionPriorityHost)
6753
+ const interactionPriorityView = createGisView(interactionPriorityHost, {
6754
+ points: [
6755
+ { id: 'ip-a', x: 0, y: 0, size: 16 },
6756
+ { id: 'ip-b', x: 120, y: 0, size: 16 },
6757
+ { id: 'ip-c', x: 240, y: 0, size: 16 }
6758
+ ],
6759
+ lines: [
6760
+ { id: 'ip-ab', from: 'ip-a', to: 'ip-b' },
6761
+ { id: 'ip-bc', from: 'ip-b', to: 'ip-c' }
6762
+ ],
6763
+ pointStyle: { color: '#64748b', size: 14 },
6764
+ lineStyle: { color: '#64748b', width: 2, opacity: 1 },
6765
+ pointRules: [{ when: { field: 'id', op: 'exists' }, override: true, style: { color: '#ef4444', size: 26, strokeColor: '#7f1d1d', strokeWidth: 4 } }],
6766
+ lineRules: [{ when: { field: 'id', op: 'exists' }, override: true, style: { color: '#ef4444', width: 9, opacity: 1 } }],
6767
+ pointSelectStyle: { color: '#22c55e', strokeColor: '#14532d', strokeWidth: 2, sizeScale: 1 },
6768
+ pointMultiSelectStyle: { color: '#2563eb', strokeColor: '#1e3a8a', strokeWidth: 2, sizeScale: 1 },
6769
+ pointBoxSelectStyle: { color: '#facc15', strokeColor: '#713f12', strokeWidth: 2, sizeScale: 1 },
6770
+ lineSelectStyle: { color: '#22c55e', width: 6, opacity: 1 },
6771
+ lineMultiSelectStyle: { color: '#2563eb', width: 7, opacity: 1 },
6772
+ cascadeHighlight: {
6773
+ scope: 'both',
6774
+ pointStyle: { color: '#db2777', strokeColor: '#831843', strokeWidth: 2, sizeScale: 1 },
6775
+ lineStyle: { color: '#06b6d4', width: 5, opacity: 1 }
6776
+ },
6777
+ features: { select: true },
6778
+ fitPadding: 60,
6779
+ showGrid: false
6780
+ })
6781
+ await settle(140)
6782
+ const interactionPointPaint = (snapshot, id) => {
6783
+ const item = interactionPriorityView.getData().points.find(point => point.id === id)
6784
+ const [x, y] = interactionPriorityView.project(item.x, item.y)
6785
+ return [...snapshot.points, ...snapshot.raised].find(point => Math.hypot(point.x - x, point.y - y) < 3)
6786
+ }
6787
+ const interactionLineColor = (snapshot, color, minWidth = 0) => [...snapshot.lines, ...snapshot.raisedLines].some(line => line.color === color && line.width >= minWidth)
6788
+ const interactionBoxA = interactionPriorityView.project(0, 0)
6789
+ const interactionBoxB = interactionPriorityView.project(120, 0)
6790
+ const interactionBox = {
6791
+ x: Math.min(interactionBoxA[0], interactionBoxB[0]) - 30,
6792
+ y: Math.min(interactionBoxA[1], interactionBoxB[1]) - 30,
6793
+ width: Math.abs(interactionBoxA[0] - interactionBoxB[0]) + 60,
6794
+ height: Math.abs(interactionBoxA[1] - interactionBoxB[1]) + 60
6795
+ }
6796
+ const interactionRuleSnapshot = interactionPriorityView.snapshotScene({ background: false })
6797
+ check('交互优先级前置:override 规则确实把未交互的节点与线路改成红色',
6798
+ interactionPointPaint(interactionRuleSnapshot, 'ip-a')?.fill === 'rgba(239,68,68,1)' && interactionLineColor(interactionRuleSnapshot, 'rgba(239,68,68,1)', 9),
6799
+ JSON.stringify({ point: interactionPointPaint(interactionRuleSnapshot, 'ip-a'), lines: interactionRuleSnapshot.lines.map(line => [line.color, line.width]) }))
6800
+
6801
+ interactionPriorityView.selectFeatures('ip-a')
6802
+ await settle(60)
6803
+ const interactionSingleSnapshot = interactionPriorityView.snapshotScene({ background: false })
6804
+ interactionPriorityView.updateOptions({
6805
+ pointRules: [{ when: { field: 'id', op: 'exists' }, override: true, style: { color: '#dc2626', size: 28, strokeColor: '#7f1d1d', strokeWidth: 4 } }],
6806
+ lineRules: [{ when: { field: 'id', op: 'exists' }, override: true, style: { color: '#dc2626', width: 10, opacity: 1 } }]
6807
+ })
6808
+ await settle(60)
6809
+ const interactionRuleSwapSnapshot = interactionPriorityView.snapshotScene({ background: false })
6810
+ check('单选颜色高于 override 规则,动态换一套强制规则后仍是选中色',
6811
+ interactionPointPaint(interactionSingleSnapshot, 'ip-a')?.fill === 'rgba(34,197,94,1)' && interactionPointPaint(interactionRuleSwapSnapshot, 'ip-a')?.fill === 'rgba(34,197,94,1)',
6812
+ JSON.stringify({ before: interactionPointPaint(interactionSingleSnapshot, 'ip-a'), after: interactionPointPaint(interactionRuleSwapSnapshot, 'ip-a') }))
6813
+
6814
+ interactionPriorityView.selectFeatures(['ip-a', 'ip-b'])
6815
+ await settle(60)
6816
+ const interactionMultiPointSnapshot = interactionPriorityView.snapshotScene({ background: false })
6817
+ check('多选节点颜色高于 override 规则', interactionPointPaint(interactionMultiPointSnapshot, 'ip-a')?.fill === 'rgba(37,99,235,1)' && interactionPointPaint(interactionMultiPointSnapshot, 'ip-b')?.fill === 'rgba(37,99,235,1)', JSON.stringify({ a: interactionPointPaint(interactionMultiPointSnapshot, 'ip-a'), b: interactionPointPaint(interactionMultiPointSnapshot, 'ip-b') }))
6818
+
6819
+ interactionPriorityView.selectFeatures(['ip-ab', 'ip-bc'])
6820
+ await settle(60)
6821
+ const interactionMultiLineSnapshot = interactionPriorityView.snapshotScene({ background: false })
6822
+ check('多选线路颜色高于 override 规则', interactionLineColor(interactionMultiLineSnapshot, 'rgba(37,99,235,1)', 7), JSON.stringify(interactionMultiLineSnapshot.lines.map(line => [line.color, line.width])))
6823
+
6824
+ interactionPriorityView.selectInBox(interactionBox, { selectionKinds: ['point'] })
6825
+ await settle(60)
6826
+ const interactionBoxPointSnapshot = interactionPriorityView.snapshotScene({ background: false })
6827
+ check('框选节点颜色高于 override 规则', interactionPointPaint(interactionBoxPointSnapshot, 'ip-a')?.fill === 'rgba(250,204,21,1)' && interactionPointPaint(interactionBoxPointSnapshot, 'ip-b')?.fill === 'rgba(250,204,21,1)', JSON.stringify({ a: interactionPointPaint(interactionBoxPointSnapshot, 'ip-a'), b: interactionPointPaint(interactionBoxPointSnapshot, 'ip-b') }))
6828
+
6829
+ interactionPriorityView.selectInBox(interactionBox, { selectionKinds: ['line'] })
6830
+ await settle(60)
6831
+ const interactionBoxLineSnapshot = interactionPriorityView.snapshotScene({ background: false })
6832
+ check('框选线路沿用 lineSelectStyle,颜色仍高于 override 规则', interactionLineColor(interactionBoxLineSnapshot, 'rgba(34,197,94,1)', 6), JSON.stringify(interactionBoxLineSnapshot.lines.map(line => [line.color, line.width])))
6833
+
6834
+ interactionPriorityView.selectFeatures(null)
6835
+ await settle(30)
6836
+ const interactionSend = (type, screen, extra = {}) => {
6837
+ const box = interactionPriorityView.canvas.getBoundingClientRect()
6838
+ interactionPriorityView.canvas.dispatchEvent(new PointerEvent(type, {
6839
+ bubbles: true,
6840
+ cancelable: true,
6841
+ pointerId: 41,
6842
+ pointerType: 'mouse',
6843
+ button: extra.button ?? 0,
6844
+ buttons: extra.buttons ?? (type === 'pointerup' ? 0 : 1),
6845
+ clientX: box.left + screen[0],
6846
+ clientY: box.top + screen[1],
6847
+ ...extra
6848
+ }))
6849
+ }
6850
+ const interactionClickAt = async (screen) => {
6851
+ interactionSend('pointerdown', screen, { buttons: 1 })
6852
+ interactionSend('pointerup', screen, { buttons: 0, button: 0 })
6853
+ await settle(80)
6854
+ }
6855
+ await interactionClickAt(interactionPriorityView.project(0, 0))
6856
+ const interactionCascadeInfo = interactionPriorityView.getCascadeHighlight()
6857
+ const interactionCascadeSnapshot = interactionPriorityView.snapshotScene({ background: false })
6858
+ check('级联颜色高于 override 规则:起始节点仍走选中色,邻接节点 / 线路走级联色',
6859
+ interactionCascadeInfo?.pointIndexes?.length === 2 && interactionPointPaint(interactionCascadeSnapshot, 'ip-a')?.fill === 'rgba(34,197,94,1)' && interactionPointPaint(interactionCascadeSnapshot, 'ip-b')?.fill === 'rgba(219,39,119,1)' && interactionLineColor(interactionCascadeSnapshot, 'rgba(6,182,212,1)', 5),
6860
+ JSON.stringify({ info: interactionCascadeInfo, a: interactionPointPaint(interactionCascadeSnapshot, 'ip-a'), b: interactionPointPaint(interactionCascadeSnapshot, 'ip-b'), lines: interactionCascadeSnapshot.lines.map(line => [line.color, line.width]) }))
6861
+ interactionPriorityView.destroy()
6862
+ interactionPriorityHost.remove()
6863
+
6864
+ /* ---------------- visualMap 图例:连续尺 / 分段色块 / 懒挂载 / 数据值域 ---------------- */
6865
+ const visualMapHost = document.createElement('div')
6866
+ visualMapHost.style.cssText = 'width:520px;height:300px'
6867
+ document.body.append(visualMapHost)
6868
+ const visualMapView = createGisView(visualMapHost, {
6869
+ points: [
6870
+ { id: 'vm-a', x: 0, y: 0, name: '甲', value: 0 },
6871
+ { id: 'vm-b', x: 60, y: 20, name: '乙', value: 10 },
6872
+ { id: 'vm-c', x: 120, y: 0, name: '丙', value: 20 }
6873
+ ],
6874
+ heatmap: { target: 'point', field: 'value', density: false, glow: false, opacity: 1 },
6875
+ visualMap: { target: 'point', title: '负荷', unit: 'kW', tickCount: 3 },
6876
+ label: { content: '{name}' },
6877
+ showGrid: false
6878
+ })
6879
+ await settle(80)
6880
+ const visualMapElement = visualMapView.getVisualMapElement()
6881
+ const visualMapReadout = visualMapView.getVisualMap()
6882
+ check('visualMap:默认跟随 heatmap 的字段与自动值域', visualMapReadout?.field === 'value' && visualMapReadout.domain[0] === 0 && visualMapReadout.domain[1] === 20 && visualMapReadout.type === 'ruler', JSON.stringify(visualMapReadout))
6883
+ check('visualMap:连续尺有渐变、刻度与单位', Boolean(visualMapElement && visualMapHost.contains(visualMapElement)) && visualMapElement.querySelectorAll('.gis-view__visual-map-tick').length === 3 && visualMapElement.querySelector('.gis-view__visual-map-ruler')?.style.background.includes('gradient') && visualMapReadout.ticks[0].label.includes('kW'), visualMapReadout?.ticks?.map(tick => tick.label).join(' / '))
6884
+ const visualMapLabelLayer = visualMapHost.querySelector('.gis-view__labels')
6885
+ let visualMapLabelMutations = 0
6886
+ const visualMapLabelObserver = new MutationObserver(records => { visualMapLabelMutations += records.length })
6887
+ if (visualMapLabelLayer) {
6888
+ visualMapLabelObserver.observe(visualMapLabelLayer, { subtree: true, childList: true, attributes: true, characterData: true })
6889
+ }
6890
+ let visualMapRootMutations = 0
6891
+ const visualMapRootObserver = new MutationObserver(records => { visualMapRootMutations += records.length })
6892
+ visualMapRootObserver.observe(visualMapElement, { subtree: true, childList: true, attributes: true, characterData: true })
6893
+ const visualMapSameConfig = { target: 'point', title: '负荷', unit: 'kW', tickCount: 3 }
6894
+ visualMapView.updateOptions({ visualMap: visualMapSameConfig })
6895
+ visualMapView.updateOptions({ visualMap: { ...visualMapSameConfig } })
6896
+ await settle(80)
6897
+ visualMapRootObserver.disconnect()
6898
+ visualMapLabelObserver.disconnect()
6899
+ check('visualMap:同签名重复更新零 DOM 写入', visualMapRootMutations === 0, `${visualMapRootMutations} 条 mutation`)
6900
+ check('visualMap:只改图例不重排标签层', visualMapLabelMutations === 0 && Boolean(visualMapLabelLayer), `${visualMapLabelMutations} 条 mutation`)
6901
+ visualMapView.updateData([
6902
+ { id: 'vm-a', x: 0, y: 0, name: '甲', value: 1000 },
6903
+ { id: 'vm-b', x: 60, y: 20, name: '乙', value: 1500 },
6904
+ { id: 'vm-c', x: 120, y: 0, name: '丙', value: 2000 }
6905
+ ], [])
6906
+ await settle(80)
6907
+ const visualMapUpdated = visualMapView.getVisualMap()
6908
+ check('visualMap:数据变化后自动值域在图例层内更新', visualMapUpdated?.domain[0] === 1000 && visualMapUpdated.domain[1] === 2000, JSON.stringify(visualMapUpdated?.domain))
6909
+ visualMapView.updateOptions({ heatmap: { target: 'point', field: 'value', density: false, glow: false, colors: ['#111827', '#f8fafc'], stops: [0, 1], opacity: 1 } })
6910
+ await settle(80)
6911
+ const visualMapInheritedGradient = visualMapElement.querySelector('.gis-view__visual-map-ruler')?.style.background
6912
+ visualMapView.updateOptions({ heatmap: { target: 'point', field: 'value', density: false, glow: false, colors: ['#111827', '#f8fafc'], stops: [0.35, 1], opacity: 0.3 } })
6913
+ await settle(80)
6914
+ const visualMapChangedGradient = visualMapElement.querySelector('.gis-view__visual-map-ruler')?.style.background
6915
+ check('visualMap:heatmap 的 stops / opacity 变化也会重画继承色带', Boolean(visualMapInheritedGradient) && visualMapChangedGradient !== visualMapInheritedGradient, `${visualMapInheritedGradient} -> ${visualMapChangedGradient}`)
6916
+ visualMapView.updateOptions({ visualMap: { type: 'piecewise', title: '负荷分档', unit: 'kW', pieces: [{ lt: 1200, label: '低' }, { gte: 1200, color: '#dd4b2e', label: '高' }] } })
6917
+ await settle(80)
6918
+ const piecewiseReadout = visualMapView.getVisualMap()
6919
+ check('visualMap:piecewise 支持 ECharts 的 lt / gte 与自定义色块', piecewiseReadout?.type === 'piecewise' && piecewiseReadout.pieces.length === 2 && piecewiseReadout.pieces[0].label === '低' && piecewiseReadout.pieces[1].color === '#dd4b2e' && visualMapHost.querySelectorAll('.gis-view__visual-map-piece').length === 2, JSON.stringify(piecewiseReadout?.pieces))
6920
+ visualMapView.updateOptions({ visualMap: null })
6921
+ await settle(80)
6922
+ check('visualMap:关掉后不保留任何图例 DOM', visualMapView.getVisualMapElement() === null && visualMapView.getVisualMap() === null && !visualMapHost.querySelector('.gis-view__visual-map'))
6923
+ visualMapView.destroy()
6924
+ visualMapHost.remove()
6925
+
6926
+ const visualMapExternal = document.createElement('div')
6927
+ visualMapExternal.style.cssText = 'width:260px;height:120px'
6928
+ document.body.append(visualMapExternal)
6929
+ const visualMapStandaloneHost = document.createElement('div')
6930
+ visualMapStandaloneHost.style.cssText = 'width:480px;height:260px'
6931
+ document.body.append(visualMapStandaloneHost)
6932
+ const visualMapStandalone = createGisView(visualMapStandaloneHost, {
6933
+ points: [{ id: 'vm-x', x: 0, y: 0, score: 12 }, { id: 'vm-y', x: 30, y: 0, score: 48 }],
6934
+ visualMap: {
6935
+ field: 'score',
6936
+ type: 'continuous',
6937
+ orient: 'horizontal',
6938
+ position: 'top-right',
6939
+ container: visualMapExternal,
6940
+ colors: ['#2f6fd0', '#dd4b2e'],
6941
+ formatter: '{value}{unit}',
6942
+ unit: '分',
6943
+ title: '评分'
6944
+ },
6945
+ showGrid: false
6946
+ })
6947
+ await settle(80)
6948
+ const standaloneReadout = visualMapStandalone.getVisualMap()
6949
+ check('visualMap:没有 heatmap 时也能按字段自动算独立图例', standaloneReadout?.domain[0] === 12 && standaloneReadout.domain[1] === 48 && standaloneReadout.unit === '分', JSON.stringify(standaloneReadout?.domain))
6950
+ check('visualMap:container / continuous / horizontal / formatter 生效', visualMapStandalone.getVisualMapElement()?.parentElement === visualMapExternal && standaloneReadout.type === 'ruler' && standaloneReadout.orient === 'horizontal' && standaloneReadout.ticks[0].label === '12分', JSON.stringify({ parent: visualMapStandalone.getVisualMapElement()?.parentElement?.className, labels: standaloneReadout?.ticks?.map(tick => tick.label) }))
6951
+ visualMapStandalone.updateOptions({ visualMap: { enabled: false } })
6952
+ await settle(80)
6953
+ check('visualMap:enabled: false 等同不配,外部容器也不留节点', visualMapStandalone.getVisualMapElement() === null && visualMapExternal.childElementCount === 0)
6954
+ visualMapStandalone.destroy()
6955
+ visualMapStandaloneHost.remove()
6956
+ visualMapExternal.remove()
6957
+
6958
+ /* 值域两端都已确定时不应再为了补另一端扫数据;用 getter 直接统计字段读取次数 */
6959
+ const visualMapFixedHost = document.createElement('div')
6960
+ visualMapFixedHost.style.cssText = 'width:420px;height:240px'
6961
+ document.body.append(visualMapFixedHost)
6962
+ let visualMapFixedReads = 0
6963
+ const visualMapFixedPoints = [{ id: 'vm-fixed-a', x: 0, y: 0 }, { id: 'vm-fixed-b', x: 40, y: 0 }]
6964
+ for (const point of visualMapFixedPoints) {
6965
+ Object.defineProperty(point, 'metric', {
6966
+ enumerable: true,
6967
+ get: () => {
6968
+ visualMapFixedReads += 1
6969
+ return 25
6970
+ }
6971
+ })
6972
+ }
6973
+ const visualMapFixed = createGisView(visualMapFixedHost, {
6974
+ points: visualMapFixedPoints,
6975
+ visualMap: { field: 'metric', domain: [0, 100] },
6976
+ showGrid: false
6977
+ })
6978
+ await settle(80)
6979
+ const visualMapFixedDomain = visualMapFixed.getVisualMap()?.domain
6980
+ check('visualMap:显式 domain 不读数据字段', visualMapFixedReads === 0 && visualMapFixedDomain?.[0] === 0 && visualMapFixedDomain?.[1] === 100, `${visualMapFixedReads} 次字段读取 · ${JSON.stringify(visualMapFixedDomain)}`)
6981
+ visualMapFixed.updateOptions({ visualMap: { field: 'metric', min: 0, max: 100 } })
6982
+ await settle(80)
6983
+ const visualMapMinMaxDomain = visualMapFixed.getVisualMap()?.domain
6984
+ check('visualMap:min / max 两端都给定时也不扫数据', visualMapFixedReads === 0 && visualMapMinMaxDomain?.[0] === 0 && visualMapMinMaxDomain?.[1] === 100, `${visualMapFixedReads} 次字段读取 · ${JSON.stringify(visualMapMinMaxDomain)}`)
6985
+ visualMapFixed.destroy()
6986
+ visualMapFixedHost.remove()
6987
+
6988
+ const visualMapReplaceHost = document.createElement('div')
6989
+ visualMapReplaceHost.style.cssText = 'width:420px;height:240px'
6990
+ document.body.append(visualMapReplaceHost)
6991
+ const visualMapReplaceConfig = { target: 'point', title: '替换后图例' }
6992
+ const visualMapReplace = createGisView(visualMapReplaceHost, {
6993
+ points: [{ id: 'vm-r', x: 0, y: 0, value: 5, load: 90 }],
6994
+ heatmap: { target: 'point', field: 'load', density: false, glow: false },
6995
+ visualMap: visualMapReplaceConfig,
6996
+ showGrid: false
6997
+ })
6998
+ await settle(80)
6999
+ const visualMapBeforeReplace = visualMapReplace.getVisualMap()
7000
+ visualMapReplace.updateOptions({
7001
+ points: [{ id: 'vm-r', x: 0, y: 0, value: 5, load: 90 }],
7002
+ visualMap: visualMapReplaceConfig,
7003
+ showGrid: false
7004
+ }, true)
7005
+ await settle(80)
7006
+ const visualMapAfterReplace = visualMapReplace.getVisualMap()
7007
+ check('visualMap:整份替换去掉 heatmap 后,图例立即改读独立字段 / 值域', visualMapBeforeReplace?.field === 'load' && visualMapAfterReplace?.field === 'value' && visualMapAfterReplace.domain[0] === 5 && visualMapAfterReplace.domain[1] === 5, JSON.stringify({ before: visualMapBeforeReplace, after: visualMapAfterReplace }))
7008
+ visualMapReplace.destroy()
7009
+ visualMapReplaceHost.remove()
7010
+
7011
+ /* visualMap:分段点选 / 键盘 / 合帧回调 / 受控选择 */
7012
+ const visualMapInteractionHost = document.createElement('div')
7013
+ visualMapInteractionHost.style.cssText = 'width:520px;height:300px'
7014
+ document.body.append(visualMapInteractionHost)
7015
+ let visualMapChangeCount = 0
7016
+ const visualMapChanges = []
7017
+ const visualMapInteractionConfig = {
7018
+ target: 'point',
7019
+ type: 'piecewise',
7020
+ interactive: true,
7021
+ selectedMode: 'multiple',
7022
+ title: '交互分档',
7023
+ pieces: [{ lt: 5, label: '低' }, { gte: 5, lt: 15, label: '中' }, { gte: 15, label: '高' }],
7024
+ onChange: (payload) => {
7025
+ visualMapChangeCount += 1
7026
+ visualMapChanges.push(payload)
7027
+ }
7028
+ }
7029
+ const visualMapInteraction = createGisView(visualMapInteractionHost, {
7030
+ points: [{ id: 'vmi-a', x: 0, y: 0, value: 1 }, { id: 'vmi-b', x: 40, y: 0, value: 10 }, { id: 'vmi-c', x: 80, y: 0, value: 20 }],
7031
+ visualMap: visualMapInteractionConfig,
7032
+ showGrid: false
7033
+ })
7034
+ await settle(80)
7035
+ const visualMapInteractionElement = visualMapInteraction.getVisualMapElement()
7036
+ const visualMapInteractionRows = [...visualMapInteractionElement.querySelectorAll('.gis-view__visual-map-piece')]
7037
+ const visualMapInitialReadout = visualMapInteraction.getVisualMap()
7038
+ check('visualMap:显式开启交互后分段可点、初始全选', visualMapInteractionElement.classList.contains('gis-view__visual-map--interactive') && getComputedStyle(visualMapInteractionElement).pointerEvents === 'auto' && visualMapInteractionRows.length === 3 && visualMapInteractionRows.every(row => row.getAttribute('role') === 'button' && row.tabIndex === 0 && row.getAttribute('aria-pressed') === 'true') && visualMapInitialReadout.selected.length === 3, JSON.stringify({ pointerEvents: getComputedStyle(visualMapInteractionElement).pointerEvents, selected: visualMapInitialReadout?.selected, roles: visualMapInteractionRows.map(row => row.getAttribute('role')) }))
7039
+ const visualMapRow = index => visualMapInteractionElement.querySelector(`.gis-view__visual-map-piece[data-index="${index}"]`)
7040
+ visualMapRow(0).dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
7041
+ await settle(40)
7042
+ const visualMapAfterClick = visualMapInteraction.getVisualMap()
7043
+ check('visualMap:点击分段切换选中态并回写读数 / onChange', visualMapAfterClick.selected.join(',') === '1,2' && visualMapRow(0).classList.contains('is-unselected') && visualMapRow(0).getAttribute('aria-pressed') === 'false' && visualMapChangeCount === 1 && visualMapChanges[0].source === 'user' && visualMapChanges[0].reason === 'piece', JSON.stringify({ selected: visualMapAfterClick?.selected, count: visualMapChangeCount, payload: visualMapChanges[0] }))
7044
+ visualMapRow(1).dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }))
7045
+ await settle(40)
7046
+ const visualMapAfterKeyboard = visualMapInteraction.getVisualMap()
7047
+ check('visualMap:分段支持 Enter / 空格键盘操作', visualMapAfterKeyboard.selected.join(',') === '2' && visualMapChangeCount === 2, JSON.stringify({ selected: visualMapAfterKeyboard?.selected, count: visualMapChangeCount }))
7048
+ const visualMapBatchStart = visualMapChangeCount
7049
+ visualMapRow(1).dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
7050
+ visualMapRow(2).dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
7051
+ visualMapRow(1).dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
7052
+ await settle(40)
7053
+ const visualMapAfterBatch = visualMapInteraction.getVisualMap()
7054
+ check('visualMap:同一帧多次点击合并成一次 onChange', visualMapAfterBatch.selected.length === 0 && visualMapChangeCount === visualMapBatchStart + 1, JSON.stringify({ selected: visualMapAfterBatch?.selected, count: visualMapChangeCount - visualMapBatchStart }))
7055
+ visualMapInteraction.updateOptions({ visualMap: { ...visualMapInteractionConfig, selected: [0] } })
7056
+ await settle(60)
7057
+ const visualMapControlled = visualMapInteraction.getVisualMap()
7058
+ check('visualMap:受控 selected 更新会重绘且不回声 onChange', visualMapControlled.selected.join(',') === '0' && visualMapRow(0).getAttribute('aria-pressed') === 'true' && visualMapRow(1).getAttribute('aria-pressed') === 'false' && visualMapChangeCount === visualMapBatchStart + 1, JSON.stringify({ selected: visualMapControlled?.selected, count: visualMapChangeCount }))
7059
+ visualMapInteraction.updateOptions({ visualMap: { ...visualMapInteractionConfig, selectedMode: 'single', selected: [0] } })
7060
+ await settle(60)
7061
+ visualMapRow(1).dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
7062
+ await settle(40)
7063
+ const visualMapSingle = visualMapInteraction.getVisualMap()
7064
+ const visualMapSingleCount = visualMapChangeCount
7065
+ visualMapRow(1).dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
7066
+ await settle(40)
7067
+ const visualMapSingleCleared = visualMapInteraction.getVisualMap()
7068
+ check('visualMap:selectedMode single 只保留一段,再点可清空', visualMapSingle.selected.join(',') === '1' && visualMapSingleCleared.selected.length === 0 && visualMapChangeCount === visualMapSingleCount + 1, JSON.stringify({ single: visualMapSingle?.selected, cleared: visualMapSingleCleared?.selected }))
7069
+ visualMapInteraction.destroy()
7070
+ visualMapInteractionHost.remove()
7071
+
7072
+ /* visualMap:连续尺区间手柄 / 拖动 / 受控 range */
7073
+ const visualMapRangeHost = document.createElement('div')
7074
+ visualMapRangeHost.style.cssText = 'width:520px;height:300px'
7075
+ document.body.append(visualMapRangeHost)
7076
+ let visualMapRangeChanges = 0
7077
+ const visualMapRangePayloads = []
7078
+ const visualMapRangeConfig = {
7079
+ target: 'point',
7080
+ type: 'ruler',
7081
+ interactive: true,
7082
+ orient: 'horizontal',
7083
+ title: '区间过滤',
7084
+ unit: 'kW',
7085
+ onChange: (payload) => {
7086
+ visualMapRangeChanges += 1
7087
+ visualMapRangePayloads.push(payload)
7088
+ }
7089
+ }
7090
+ const visualMapRangeView = createGisView(visualMapRangeHost, {
7091
+ points: [{ id: 'vmr-a', x: 0, y: 0, value: 0 }, { id: 'vmr-b', x: 40, y: 0, value: 50 }, { id: 'vmr-c', x: 80, y: 0, value: 100 }],
7092
+ visualMap: visualMapRangeConfig,
7093
+ showGrid: false
7094
+ })
7095
+ await settle(80)
7096
+ const visualMapRangeElement = visualMapRangeView.getVisualMapElement()
7097
+ const visualMapRangeTrack = visualMapRangeElement.querySelector('.gis-view__visual-map-ruler')
7098
+ const visualMapRangeHandles = [...visualMapRangeElement.querySelectorAll('.gis-view__visual-map-range-handle')]
7099
+ const visualMapRangeInitial = visualMapRangeView.getVisualMap()
7100
+ check('visualMap:interactive 连续尺默认 calculable 并显示两端手柄', visualMapRangeInitial.calculable === true && visualMapRangeInitial.range[0] === 0 && visualMapRangeInitial.range[1] === 100 && visualMapRangeInitial.rangeRatios.join(',') === '0,1' && visualMapRangeHandles.length === 2 && visualMapRangeHandles.every(handle => handle.getAttribute('role') === 'slider'), JSON.stringify({ range: visualMapRangeInitial?.range, ratios: visualMapRangeInitial?.rangeRatios }))
7101
+ visualMapRangeHandles[1].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true, cancelable: true }))
7102
+ await settle(40)
7103
+ const visualMapRangeKeyboard = visualMapRangeView.getVisualMap()
7104
+ check('visualMap:区间手柄支持键盘步进并触发 range 回调', Math.abs(visualMapRangeKeyboard.range[0]) < 1e-6 && Math.abs(visualMapRangeKeyboard.range[1] - 98) < 1e-6 && visualMapRangeChanges === 1 && visualMapRangePayloads[0].reason === 'range', JSON.stringify({ range: visualMapRangeKeyboard?.range, count: visualMapRangeChanges }))
7105
+ const visualMapRangeRect = visualMapRangeTrack.getBoundingClientRect()
7106
+ const visualMapPointer = (type, ratio, buttons) => visualMapRangeHandles[1].dispatchEvent(new PointerEvent(type, {
7107
+ bubbles: true,
7108
+ cancelable: true,
7109
+ pointerId: 71,
7110
+ pointerType: 'mouse',
7111
+ button: 0,
7112
+ buttons,
7113
+ clientX: visualMapRangeRect.left + visualMapRangeRect.width * ratio,
7114
+ clientY: visualMapRangeRect.top + visualMapRangeRect.height / 2
7115
+ }))
7116
+ const visualMapDragStart = visualMapRangeChanges
7117
+ visualMapPointer('pointerdown', 0.98, 1)
7118
+ visualMapPointer('pointermove', 0.8, 1)
7119
+ visualMapPointer('pointermove', 0.65, 1)
7120
+ visualMapPointer('pointermove', 0.5, 1)
7121
+ visualMapPointer('pointerup', 0.5, 0)
7122
+ await settle(50)
7123
+ const visualMapRangeDragged = visualMapRangeView.getVisualMap()
7124
+ check('visualMap:拖动区间手柄更新 range,连续事件只回调一次', Math.abs(visualMapRangeDragged.range[0]) < 1e-6 && Math.abs(visualMapRangeDragged.range[1] - 50) < 1e-6 && visualMapRangeChanges === visualMapDragStart + 1, JSON.stringify({ range: visualMapRangeDragged?.range, callbacks: visualMapRangeChanges - visualMapDragStart }))
7125
+ visualMapRangeView.updateOptions({ visualMap: { ...visualMapRangeConfig, range: [25, 75] } })
7126
+ await settle(60)
7127
+ const visualMapRangeControlled = visualMapRangeView.getVisualMap()
7128
+ const visualMapRangeWindow = visualMapRangeElement.querySelector('.gis-view__visual-map-range')
7129
+ check('visualMap:受控 range 更新手柄位置且不回声 onChange', visualMapRangeControlled.range.join(',') === '25,75' && Math.abs(visualMapRangeControlled.rangeRatios[0] - 0.25) < 1e-6 && Math.abs(visualMapRangeControlled.rangeRatios[1] - 0.75) < 1e-6 && visualMapRangeWindow.style.left === '25%' && visualMapRangeWindow.style.width === '50%' && visualMapRangeChanges === visualMapDragStart + 1, JSON.stringify({ range: visualMapRangeControlled?.range, ratios: visualMapRangeControlled?.rangeRatios, style: [visualMapRangeWindow.style.left, visualMapRangeWindow.style.width] }))
7130
+ const visualMapRangeCopy = visualMapRangeView.getVisualMap()
7131
+ visualMapRangeCopy.range[0] = -999
7132
+ visualMapRangeCopy.rangeRatios[1] = 9
7133
+ const visualMapRangeFresh = visualMapRangeView.getVisualMap()
7134
+ check('visualMap:读数里的 range / rangeRatios 是独立副本', visualMapRangeFresh.range[0] === 25 && visualMapRangeFresh.rangeRatios[1] === 0.75, JSON.stringify({ range: visualMapRangeFresh.range, ratios: visualMapRangeFresh.rangeRatios }))
7135
+ visualMapRangeView.destroy()
7136
+ visualMapRangeHost.remove()
7137
+
7138
+ /* visualMap:选区真正进入热力上色(分段 / 连续区间都门控,而不只是回显读数) */
7139
+ const visualMapGateHost = document.createElement('div')
7140
+ visualMapGateHost.style.cssText = 'width:520px;height:300px'
7141
+ document.body.append(visualMapGateHost)
7142
+ const visualMapGateHeatmap = {
7143
+ enabled: true,
7144
+ target: 'point',
7145
+ field: 'value',
7146
+ domain: [0, 20],
7147
+ colors: ['#ff0000', '#ff0000'],
7148
+ opacity: 1,
7149
+ density: false,
7150
+ glow: false
7151
+ }
7152
+ const visualMapGateConfig = {
7153
+ target: 'point',
7154
+ type: 'piecewise',
7155
+ interactive: true,
7156
+ selectedMode: 'multiple',
7157
+ pieces: [{ lt: 5 }, { gte: 5, lt: 15 }, { gte: 15 }]
7158
+ }
7159
+ const visualMapGateView = createGisView(visualMapGateHost, {
7160
+ points: [{ id: 'vmg-a', x: 0, y: 0, value: 1 }, { id: 'vmg-b', x: 40, y: 0, value: 10 }, { id: 'vmg-c', x: 80, y: 0, value: 20 }],
7161
+ pointStyle: { color: '#ffffff', size: 12 },
7162
+ heatmap: visualMapGateHeatmap,
7163
+ visualMap: visualMapGateConfig,
7164
+ minimap: { width: 180, height: 120 },
7165
+ showGrid: false
7166
+ })
7167
+ await settle(100)
7168
+ const visualMapGateFills = () => visualMapGateView.snapshotScene().points.map(point => point.fill)
7169
+ const visualMapGateMiniCanvas = visualMapGateView.getMinimapElement()?.querySelector('.gis-view__minimap-canvas')
7170
+ const visualMapGateMiniRed = () => {
7171
+ const context = visualMapGateMiniCanvas?.getContext('2d')
7172
+ if (!context) {
7173
+ return -1
7174
+ }
7175
+ const data = context.getImageData(0, 0, visualMapGateMiniCanvas.width, visualMapGateMiniCanvas.height).data
7176
+ let count = 0
7177
+ for (let index = 0; index < data.length; index += 4) {
7178
+ if (data[index] > 180 && data[index + 1] < 100 && data[index + 2] < 100) {
7179
+ count += 1
7180
+ }
7181
+ }
7182
+ return count
7183
+ }
7184
+ const visualMapGateInitial = visualMapGateFills()
7185
+ const visualMapGateMiniInitial = visualMapGateMiniRed()
7186
+ visualMapGateView.getVisualMapElement().querySelector('.gis-view__visual-map-piece[data-index="0"]').dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
7187
+ await settle(80)
7188
+ const visualMapGatePiecewise = visualMapGateFills()
7189
+ visualMapGateView.updateOptions({ visualMap: { ...visualMapGateConfig, selected: [2] } })
7190
+ await settle(80)
7191
+ const visualMapGateControlled = visualMapGateFills()
7192
+ const visualMapGateMiniControlled = visualMapGateMiniRed()
7193
+ check('visualMap:取消分段后对应值退出热力上色,再选一段只保留该档',
7194
+ visualMapGateInitial.every(fill => fill === 'rgba(255,0,0,1)') &&
7195
+ visualMapGatePiecewise[0] === 'rgba(255,255,255,1)' && visualMapGatePiecewise[1] === 'rgba(255,0,0,1)' && visualMapGatePiecewise[2] === 'rgba(255,0,0,1)' &&
7196
+ visualMapGateControlled[0] === 'rgba(255,255,255,1)' && visualMapGateControlled[1] === 'rgba(255,255,255,1)' && visualMapGateControlled[2] === 'rgba(255,0,0,1)',
7197
+ JSON.stringify({ initial: visualMapGateInitial, piecewise: visualMapGatePiecewise, controlled: visualMapGateControlled }))
7198
+ check('visualMap:鹰眼图继承主图热力时也按同一选区门控',
7199
+ visualMapGateMiniInitial > 0 && visualMapGateMiniControlled > 0 && visualMapGateMiniControlled < visualMapGateMiniInitial * 0.8,
7200
+ `初始红色 ${visualMapGateMiniInitial} px · 选中高段后 ${visualMapGateMiniControlled} px`)
7201
+ const visualMapGateMiniGreen = () => {
7202
+ const context = visualMapGateMiniCanvas?.getContext('2d')
7203
+ if (!context) {
7204
+ return -1
7205
+ }
7206
+ const data = context.getImageData(0, 0, visualMapGateMiniCanvas.width, visualMapGateMiniCanvas.height).data
7207
+ let count = 0
7208
+ for (let index = 0; index < data.length; index += 4) {
7209
+ if (data[index] < 100 && data[index + 1] > 180 && data[index + 2] < 100) {
7210
+ count += 1
7211
+ }
7212
+ }
7213
+ return count
7214
+ }
7215
+ visualMapGateView.updateOptions({ minimap: { width: 180, height: 120, heatmap: { target: 'point', field: 'value', domain: [0, 20], colors: ['#00ff00', '#00ff00'], opacity: 1, density: false, glow: false } } })
7216
+ await settle(100)
7217
+ const visualMapGateMiniIndependent = visualMapGateMiniGreen()
7218
+ visualMapGateView.updateOptions({ visualMap: { ...visualMapGateConfig, selected: [0] } })
7219
+ await settle(100)
7220
+ const visualMapGateMiniIndependentAfter = visualMapGateMiniGreen()
7221
+ check('visualMap:显式 minimap.heatmap 仍按自己的配置取色,不被主图选区门控',
7222
+ visualMapGateMiniIndependent > 0 && visualMapGateMiniIndependentAfter === visualMapGateMiniIndependent,
7223
+ `绿色 ${visualMapGateMiniIndependent} px → ${visualMapGateMiniIndependentAfter} px`)
7224
+ visualMapGateView.destroy()
7225
+ visualMapGateHost.remove()
7226
+
7227
+ const visualMapDensityGateHost = document.createElement('div')
7228
+ visualMapDensityGateHost.style.cssText = 'width:520px;height:300px'
7229
+ document.body.append(visualMapDensityGateHost)
7230
+ const visualMapDensityGateConfig = { target: 'point', type: 'piecewise', interactive: true, selectedMode: 'multiple', pieces: [{ lt: 5 }, { gte: 5, lt: 15 }, { gte: 15 }], selected: [2] }
7231
+ const visualMapDensityGateView = createGisView(visualMapDensityGateHost, {
7232
+ points: [{ id: 'vmdg-a', x: 0, y: 0, value: 1 }, { id: 'vmdg-b', x: 40, y: 0, value: 10 }, { id: 'vmdg-c', x: 80, y: 0, value: 20 }],
7233
+ heatmap: { ...visualMapGateHeatmap, density: { resolution: 96, radiusRatio: 0.12, threshold: 0 } },
7234
+ visualMap: visualMapDensityGateConfig,
7235
+ showGrid: false
7236
+ })
7237
+ await settle(140)
7238
+ const visualMapDensityGated = visualMapDensityGateView.getStats().heatDensity
7239
+ visualMapDensityGateView.updateOptions({ visualMap: { ...visualMapDensityGateConfig, selected: [0, 1, 2] } })
7240
+ await settle(140)
7241
+ const visualMapDensityExpanded = visualMapDensityGateView.getStats().heatDensity
7242
+ check('visualMap:密度场也按选区重新采样,落选值不再贡献密度',
7243
+ visualMapDensityGated?.samples === 1 && visualMapDensityExpanded?.samples === 3,
7244
+ JSON.stringify({ gated: visualMapDensityGated, expanded: visualMapDensityExpanded }))
7245
+ visualMapDensityGateView.destroy()
7246
+ visualMapDensityGateHost.remove()
7247
+
7248
+ const visualMapRangeGateHost = document.createElement('div')
7249
+ visualMapRangeGateHost.style.cssText = 'width:520px;height:300px'
7250
+ document.body.append(visualMapRangeGateHost)
7251
+ const visualMapRangeGateConfig = { target: 'point', type: 'ruler', interactive: true, range: [25, 75] }
7252
+ const visualMapRangeGateView = createGisView(visualMapRangeGateHost, {
7253
+ points: [{ id: 'vmrg-a', x: 0, y: 0, value: 0 }, { id: 'vmrg-b', x: 40, y: 0, value: 50 }, { id: 'vmrg-c', x: 80, y: 0, value: 100 }],
7254
+ pointStyle: { color: '#ffffff', size: 12 },
7255
+ heatmap: { ...visualMapGateHeatmap, domain: [0, 100] },
7256
+ visualMap: visualMapRangeGateConfig,
7257
+ showGrid: false
7258
+ })
7259
+ await settle(100)
7260
+ const visualMapRangeGateFills = () => visualMapRangeGateView.snapshotScene().points.map(point => point.fill)
7261
+ const visualMapRangeGateInitial = visualMapRangeGateFills()
7262
+ visualMapRangeGateView.updateOptions({ visualMap: { ...visualMapRangeGateConfig, range: [0, 100] } })
7263
+ await settle(80)
7264
+ const visualMapRangeGateExpanded = visualMapRangeGateFills()
7265
+ check('visualMap:连续尺区间外不参与热力上色,拖动扩回整段后全部恢复',
7266
+ visualMapRangeGateInitial[0] === 'rgba(255,255,255,1)' && visualMapRangeGateInitial[1] === 'rgba(255,0,0,1)' && visualMapRangeGateInitial[2] === 'rgba(255,255,255,1)' &&
7267
+ visualMapRangeGateExpanded.every(fill => fill === 'rgba(255,0,0,1)'),
7268
+ JSON.stringify({ initial: visualMapRangeGateInitial, expanded: visualMapRangeGateExpanded }))
7269
+ visualMapRangeGateView.destroy()
7270
+ visualMapRangeGateHost.remove()
7271
+
7272
+ /* visualMap:render(context) 完全自定义 + 上下文交互 */
7273
+ const visualMapCustomHost = document.createElement('div')
7274
+ visualMapCustomHost.style.cssText = 'width:520px;height:300px'
7275
+ document.body.append(visualMapCustomHost)
7276
+ let visualMapCustomCalls = 0
7277
+ let visualMapCustomContext = null
7278
+ let visualMapCustomChanges = 0
7279
+ const visualMapCustomRender = (context) => {
7280
+ visualMapCustomCalls += 1
7281
+ visualMapCustomContext = context
7282
+ const wrapper = document.createElement('div')
7283
+ wrapper.className = 'vm-custom-content'
7284
+ const readout = document.createElement('span')
7285
+ readout.className = 'vm-custom-readout'
7286
+ readout.textContent = `${context.title}:${context.selected.join('/')}`
7287
+ const button = document.createElement('button')
7288
+ button.type = 'button'
7289
+ button.className = 'vm-custom-select-high'
7290
+ button.textContent = '只选高档'
7291
+ button.addEventListener('click', () => context.setSelection([2]))
7292
+ wrapper.append(readout, button)
7293
+ return wrapper
7294
+ }
7295
+ const visualMapCustomConfig = {
7296
+ target: 'point',
7297
+ type: 'piecewise',
7298
+ interactive: true,
7299
+ selectedMode: 'multiple',
7300
+ title: '自定义图例',
7301
+ pieces: [{ lt: 5, label: '低' }, { gte: 5, lt: 15, label: '中' }, { gte: 15, label: '高' }],
7302
+ render: visualMapCustomRender,
7303
+ onChange: () => { visualMapCustomChanges += 1 }
7304
+ }
7305
+ const visualMapCustomView = createGisView(visualMapCustomHost, {
7306
+ points: [{ id: 'vmc-a', x: 0, y: 0, value: 1 }, { id: 'vmc-b', x: 40, y: 0, value: 10 }, { id: 'vmc-c', x: 80, y: 0, value: 20 }],
7307
+ visualMap: visualMapCustomConfig,
7308
+ showGrid: false
7309
+ })
7310
+ await settle(80)
7311
+ const visualMapCustomElement = visualMapCustomView.getVisualMapElement()
7312
+ const visualMapCustomInitial = visualMapCustomView.getVisualMap()
7313
+ check('visualMap:自定义 render 返回 DOM、隐藏内置标题并提供上下文读数', visualMapCustomCalls === 1 && visualMapCustomElement.querySelector('.vm-custom-content') !== null && visualMapCustomElement.querySelector('.gis-view__visual-map-title').hidden === true && visualMapCustomContext.domain.join(',') === '1,20' && visualMapCustomContext.selected.join(',') === '0,1,2' && visualMapCustomInitial.selected.length === 3, JSON.stringify({ calls: visualMapCustomCalls, hidden: visualMapCustomElement.querySelector('.gis-view__visual-map-title')?.hidden, context: visualMapCustomContext && { domain: visualMapCustomContext.domain, selected: visualMapCustomContext.selected } }))
7314
+ visualMapCustomElement.querySelector('.vm-custom-select-high').dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
7315
+ await settle(50)
7316
+ const visualMapCustomSelected = visualMapCustomView.getVisualMap()
7317
+ check('visualMap:自定义上下文 setSelection 更新读数并回调 api 来源', visualMapCustomSelected.selected.join(',') === '2' && visualMapCustomChanges === 1 && visualMapCustomCalls === 1, JSON.stringify({ selected: visualMapCustomSelected?.selected, changes: visualMapCustomChanges, calls: visualMapCustomCalls }))
7318
+ visualMapCustomView.updateOptions({ visualMap: { ...visualMapCustomConfig, title: '自定义图例 2' } })
7319
+ await settle(60)
7320
+ check('visualMap:自定义 render 的配置签名变化会重绘,并保留用户选区', visualMapCustomCalls === 2 && visualMapCustomElement.querySelector('.vm-custom-readout')?.textContent === '自定义图例 2:2', JSON.stringify({ calls: visualMapCustomCalls, text: visualMapCustomElement.querySelector('.vm-custom-readout')?.textContent }))
7321
+ visualMapCustomView.updateOptions({ visualMap: { ...visualMapCustomConfig, title: '字符串图例', render: context => `<strong class="vm-custom-string">${context.domain[0]} ~ ${context.domain[1]}</strong>` } })
7322
+ await settle(60)
7323
+ check('visualMap:自定义 render 也可返回 HTML 字符串', visualMapCustomElement.querySelector('.vm-custom-string')?.textContent === '1 ~ 20', visualMapCustomElement.innerHTML)
7324
+ visualMapCustomView.destroy()
7325
+ visualMapCustomHost.remove()
7326
+
7327
+ /* visualMap:outOfRange 三种落选策略;network 只做视觉门控,不改数据 / 筛选 / 通道声明 */
7328
+ const visualMapOutOfRangeHost = document.createElement('div')
7329
+ visualMapOutOfRangeHost.style.cssText = 'width:520px;height:300px'
7330
+ document.body.append(visualMapOutOfRangeHost)
7331
+ const visualMapOutOfRangeChanges = []
7332
+ const visualMapOutOfRangeHeatmap = {
7333
+ enabled: true,
7334
+ target: 'point',
7335
+ field: 'value',
7336
+ domain: [0, 20],
7337
+ colors: ['#ff0000', '#ff0000'],
7338
+ opacity: 1,
7339
+ density: false,
7340
+ glow: false
7341
+ }
7342
+ const visualMapOutOfRangeConfig = {
7343
+ target: 'point',
7344
+ field: 'value',
7345
+ type: 'piecewise',
7346
+ interactive: true,
7347
+ selectedMode: 'multiple',
7348
+ pieces: [{ lt: 5 }, { gte: 5, lt: 15 }, { gte: 15 }],
7349
+ selected: [1],
7350
+ onChange: payload => visualMapOutOfRangeChanges.push(payload)
7351
+ }
7352
+ const visualMapOutOfRangeView = createGisView(visualMapOutOfRangeHost, {
7353
+ points: [
7354
+ { id: 'vmor-a', x: -60, y: 0, value: 1 },
7355
+ { id: 'vmor-b', x: 0, y: 0, value: 10 },
7356
+ { id: 'vmor-c', x: 60, y: 0, value: 20 }
7357
+ ],
7358
+ pointStyle: { color: '#ffffff', size: 14 },
7359
+ heatmap: visualMapOutOfRangeHeatmap,
7360
+ visualMap: visualMapOutOfRangeConfig,
7361
+ minimap: {
7362
+ width: 180,
7363
+ height: 120,
7364
+ heatmap: {
7365
+ target: 'point',
7366
+ field: 'value',
7367
+ domain: [0, 20],
7368
+ colors: ['#00ff00', '#00ff00'],
7369
+ opacity: 1,
7370
+ density: false,
7371
+ glow: false
7372
+ }
7373
+ },
7374
+ showGrid: false
7375
+ })
7376
+ await settle()
7377
+ const visualMapOutOfRangeScenePoints = () => {
7378
+ const scene = visualMapOutOfRangeView.snapshotScene()
7379
+ return [...scene.points, ...scene.raised]
7380
+ }
7381
+ const visualMapOutOfRangeCanvas = visualMapOutOfRangeView.getMinimapElement()?.querySelector('.gis-view__minimap-canvas')
7382
+ const visualMapOutOfRangeMiniGreen = () => {
7383
+ const context = visualMapOutOfRangeCanvas?.getContext('2d')
7384
+ if (!context) {
7385
+ return -1
7386
+ }
7387
+ const data = context.getImageData(0, 0, visualMapOutOfRangeCanvas.width, visualMapOutOfRangeCanvas.height).data
7388
+ let count = 0
7389
+ for (let index = 0; index < data.length; index += 4) {
7390
+ if (data[index] < 100 && data[index + 1] > 180 && data[index + 2] < 100) {
7391
+ count += 1
7392
+ }
7393
+ }
7394
+ return count
7395
+ }
7396
+ const visualMapOutOfRangeInitialFills = visualMapOutOfRangeScenePoints().map(point => point.fill)
7397
+ const visualMapOutOfRangeMiniInitial = visualMapOutOfRangeMiniGreen()
7398
+ check('visualMap:outOfRange 缺省 heatmap,只退热力,落选节点仍在画布上',
7399
+ visualMapOutOfRangeView.getVisualMap()?.outOfRange === 'heatmap' &&
7400
+ visualMapOutOfRangeInitialFills.length === 3 && visualMapOutOfRangeInitialFills[0] === 'rgba(255,255,255,1)' && visualMapOutOfRangeInitialFills[1] === 'rgba(255,0,0,1)' && visualMapOutOfRangeInitialFills[2] === 'rgba(255,255,255,1)',
7401
+ JSON.stringify({ mode: visualMapOutOfRangeView.getVisualMap()?.outOfRange, fills: visualMapOutOfRangeInitialFills }))
7402
+ visualMapOutOfRangeView.selectFeatures('vmor-a')
7403
+ const visualMapOutOfRangeFilterBefore = visualMapOutOfRangeView.getFilterResult()
7404
+ visualMapOutOfRangeView.updateOptions({ visualMap: { ...visualMapOutOfRangeConfig, outOfRange: 'network' } })
7405
+ await settle()
7406
+ const visualMapOutOfRangeHiddenIds = visualMapOutOfRangeView.listPoints().map(point => point.item.id)
7407
+ const visualMapOutOfRangeMiniHidden = visualMapOutOfRangeMiniGreen()
7408
+ check('visualMap:network 让落选点退出画布与可见列表,数据 / 筛选 / 选中集原样保留',
7409
+ visualMapOutOfRangeHiddenIds.join(',') === 'vmor-b' &&
7410
+ visualMapOutOfRangeScenePoints().length === 1 &&
7411
+ visualMapOutOfRangeView.getData().points.length === 3 &&
7412
+ visualMapOutOfRangeView.getVisibleData().points.length === 3 &&
7413
+ visualMapOutOfRangeView.getFilterResult() === visualMapOutOfRangeFilterBefore &&
7414
+ visualMapOutOfRangeView.getSelectionData().ids.join(',') === 'vmor-a',
7415
+ JSON.stringify({ visible: visualMapOutOfRangeHiddenIds, data: visualMapOutOfRangeView.getData().points.length, selection: visualMapOutOfRangeView.getSelectionData().ids }))
7416
+ check('visualMap:network 的隐藏集合同步到使用独立热力配置的鹰眼图',
7417
+ visualMapOutOfRangeMiniInitial > 0 && visualMapOutOfRangeMiniHidden > 0 && visualMapOutOfRangeMiniHidden < visualMapOutOfRangeMiniInitial * 0.75,
7418
+ `初始绿色 ${visualMapOutOfRangeMiniInitial} px · 隐藏后 ${visualMapOutOfRangeMiniHidden} px`)
7419
+ visualMapOutOfRangeView.updateOptions({ visualMap: { ...visualMapOutOfRangeConfig, outOfRange: 'network', selected: [0, 1, 2] } })
7420
+ await settleMs(300)
7421
+ const visualMapOutOfRangeMiniRestored = visualMapOutOfRangeMiniGreen()
7422
+ check('visualMap:network 恢复全选后落选点回到画布,原选中集仍在',
7423
+ visualMapOutOfRangeView.listPoints().length === 3 && visualMapOutOfRangeScenePoints().length === 3 &&
7424
+ visualMapOutOfRangeView.getSelectionData().ids.join(',') === 'vmor-a' && visualMapOutOfRangeMiniRestored >= visualMapOutOfRangeMiniInitial * 0.8,
7425
+ JSON.stringify({ visible: visualMapOutOfRangeView.listPoints().map(point => point.item.id), selection: visualMapOutOfRangeView.getSelectionData().ids, mini: [visualMapOutOfRangeMiniInitial, visualMapOutOfRangeMiniHidden, visualMapOutOfRangeMiniRestored] }))
7426
+ visualMapOutOfRangeView.updateOptions({ visualMap: { ...visualMapOutOfRangeConfig, outOfRange: 'none', selected: [0, 1, 2] } })
7427
+ await settle()
7428
+ const visualMapOutOfRangeNoneChanges = visualMapOutOfRangeChanges.length
7429
+ visualMapOutOfRangeView.getVisualMapElement().querySelectorAll('.gis-view__visual-map-piece')[1].dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
7430
+ await settle()
7431
+ const visualMapOutOfRangeNoneFills = visualMapOutOfRangeScenePoints().map(point => point.fill)
7432
+ check('visualMap:outOfRange none 不做事先门控,选区变化只通过 onChange 交给业务',
7433
+ visualMapOutOfRangeNoneFills.length === 3 && visualMapOutOfRangeNoneFills.every(fill => fill === 'rgba(255,0,0,1)') &&
7434
+ visualMapOutOfRangeView.getVisualMap().selected.join(',') === '0,2' &&
7435
+ visualMapOutOfRangeChanges.length === visualMapOutOfRangeNoneChanges + 1 && visualMapOutOfRangeChanges.at(-1)?.outOfRange === 'none',
7436
+ JSON.stringify({ fills: visualMapOutOfRangeNoneFills, selected: visualMapOutOfRangeView.getVisualMap().selected, changes: visualMapOutOfRangeChanges.length - visualMapOutOfRangeNoneChanges }))
7437
+ visualMapOutOfRangeView.destroy()
7438
+ visualMapOutOfRangeHost.remove()
7439
+
7440
+ const visualMapLineOutOfRangeHost = document.createElement('div')
7441
+ visualMapLineOutOfRangeHost.style.cssText = 'width:520px;height:300px'
7442
+ document.body.append(visualMapLineOutOfRangeHost)
7443
+ const visualMapLineOutOfRangeConfig = {
7444
+ target: 'line',
7445
+ field: 'value',
7446
+ type: 'piecewise',
7447
+ interactive: true,
7448
+ selectedMode: 'multiple',
7449
+ pieces: [{ lt: 5 }, { gte: 5, lt: 15 }, { gte: 15 }],
7450
+ selected: [0, 1],
7451
+ outOfRange: 'network'
7452
+ }
7453
+ const visualMapLineOutOfRangeView = createGisView(visualMapLineOutOfRangeHost, {
7454
+ points: [
7455
+ { id: 'vml-a', x: 0, y: 0, size: 12 },
7456
+ { id: 'vml-b', x: 180, y: 60, size: 12 }
7457
+ ],
7458
+ lines: [
7459
+ { id: 'vml-solo-low', path: [[0, 130], [180, 130]], value: 1, width: 6 },
7460
+ { id: 'vml-solo-high', path: [[0, 160], [180, 160]], value: 10, width: 6 },
7461
+ { id: 'vml-ch-low', path: [[0, 0], [90, 30], [180, 60]], value: 1, width: 6 },
7462
+ { id: 'vml-ch-high', path: [[0, 10], [90, 40], [180, 70]], value: 10, width: 6 }
7463
+ ],
7464
+ channels: [
7465
+ { id: 'vml-ch-low', from: 'vml-a', to: 'vml-b', lineIds: ['vml-ch-low'], value: 1 },
7466
+ { id: 'vml-ch-high', from: 'vml-a', to: 'vml-b', lineIds: ['vml-ch-high'], value: 10 }
7467
+ ],
7468
+ visualMap: visualMapLineOutOfRangeConfig,
7469
+ showGrid: false
7470
+ })
7471
+ await settle()
7472
+ const visualMapLineSceneCount = () => {
7473
+ const scene = visualMapLineOutOfRangeView.snapshotScene()
7474
+ return scene.lines.length + scene.raisedLines.length
7475
+ }
7476
+ const visualMapLineChannelsBefore = JSON.stringify(visualMapLineOutOfRangeView.getChannels())
7477
+ const visualMapLineChannelLowBefore = JSON.stringify(visualMapLineOutOfRangeView.getChannel('vml-ch-low'))
7478
+ const visualMapLineFilterBefore = visualMapLineOutOfRangeView.getFilterResult()
7479
+ visualMapLineOutOfRangeView.selectFeatures('vml-solo-low')
7480
+ visualMapLineOutOfRangeView.updateOptions({ visualMap: { ...visualMapLineOutOfRangeConfig, selected: [1] } })
7481
+ await settle()
7482
+ const visualMapLineHiddenIds = visualMapLineOutOfRangeView.listLines().map(line => line.item.id)
7483
+ check('visualMap:network + line 只隐藏落选独立线路,不误伤同值的通道回线',
7484
+ !visualMapLineHiddenIds.includes('vml-solo-low') && visualMapLineHiddenIds.includes('vml-solo-high') &&
7485
+ visualMapLineHiddenIds.includes('vml-ch-low') && visualMapLineHiddenIds.includes('vml-ch-high') &&
7486
+ visualMapLineSceneCount() === 3 && visualMapLineOutOfRangeView.getData().lines.length === 4 &&
7487
+ visualMapLineOutOfRangeView.getFilterResult() === visualMapLineFilterBefore && visualMapLineOutOfRangeView.getSelectionData().ids.join(',') === 'vml-solo-low',
7488
+ JSON.stringify({ visible: visualMapLineHiddenIds, data: visualMapLineOutOfRangeView.getData().lines.length, selection: visualMapLineOutOfRangeView.getSelectionData().ids }))
7489
+ visualMapLineOutOfRangeView.updateOptions({ visualMap: { ...visualMapLineOutOfRangeConfig, target: 'channel', selected: [1] } })
7490
+ await settle()
7491
+ const visualMapChannelHiddenIds = visualMapLineOutOfRangeView.listLines().map(line => line.item.id)
7492
+ check('visualMap:network + channel 按声明隐藏整条落选通道,通道声明与数据不变',
7493
+ visualMapChannelHiddenIds.includes('vml-solo-low') && visualMapChannelHiddenIds.includes('vml-solo-high') &&
7494
+ !visualMapChannelHiddenIds.includes('vml-ch-low') && visualMapChannelHiddenIds.includes('vml-ch-high') &&
7495
+ visualMapLineSceneCount() === 3 &&
7496
+ JSON.stringify(visualMapLineOutOfRangeView.getChannels()) === visualMapLineChannelsBefore &&
7497
+ JSON.stringify(visualMapLineOutOfRangeView.getChannel('vml-ch-low')) === visualMapLineChannelLowBefore,
7498
+ JSON.stringify({ visible: visualMapChannelHiddenIds, channels: visualMapLineOutOfRangeView.getChannels().map(channel => channel.channelId) }))
7499
+ visualMapLineOutOfRangeView.updateOptions({ visualMap: { ...visualMapLineOutOfRangeConfig, target: 'channel', selected: [0, 1] } })
7500
+ await settle()
7501
+ check('visualMap:channel 恢复全选后所有通道回线回到画布',
7502
+ visualMapLineOutOfRangeView.listLines().length === 4 && visualMapLineSceneCount() === 4,
7503
+ JSON.stringify({ visible: visualMapLineOutOfRangeView.listLines().map(line => line.item.id) }))
7504
+ visualMapLineOutOfRangeView.destroy()
7505
+ visualMapLineOutOfRangeHost.remove()
7506
+
7507
+ const visualMapMultiTargetHost = document.createElement('div')
7508
+ visualMapMultiTargetHost.style.cssText = 'width:520px;height:300px'
7509
+ document.body.append(visualMapMultiTargetHost)
7510
+ const visualMapMultiTargetConfig = {
7511
+ field: 'value',
7512
+ type: 'piecewise',
7513
+ interactive: true,
7514
+ selectedMode: 'multiple',
7515
+ pieces: [{ lt: 5 }, { gte: 5 }],
7516
+ selected: [1],
7517
+ outOfRange: 'network'
7518
+ }
7519
+ const visualMapMultiTargetView = createGisView(visualMapMultiTargetHost, {
7520
+ points: [
7521
+ { id: 'vmmt-low', x: 0, y: 0, size: 12 },
7522
+ { id: 'vmmt-high', x: 180, y: 60, size: 12 }
7523
+ ],
7524
+ lines: [
7525
+ { id: 'vmmt-solo-low', path: [[0, 130], [180, 130]], value: 1, width: 6 },
7526
+ { id: 'vmmt-solo-high', path: [[0, 160], [180, 160]], value: 10, width: 6 },
7527
+ { id: 'vmmt-ch-low', path: [[0, 0], [90, 30], [180, 60]], value: 1, width: 6 },
7528
+ { id: 'vmmt-ch-high', path: [[0, 10], [90, 40], [180, 70]], value: 10, width: 6 }
7529
+ ],
7530
+ channels: [
7531
+ { id: 'vmmt-ch-low', from: 'vmmt-low', to: 'vmmt-high', lineIds: ['vmmt-ch-low'], value: 1 },
7532
+ { id: 'vmmt-ch-high', from: 'vmmt-low', to: 'vmmt-high', lineIds: ['vmmt-ch-high'], value: 10 }
7533
+ ],
7534
+ heatmap: { target: ['point', 'line', 'channel'], field: 'value', density: false, glow: false, opacity: 1 },
7535
+ visualMap: visualMapMultiTargetConfig,
7536
+ showGrid: false
7537
+ })
7538
+ await settle()
7539
+ const visualMapMultiTargetInitial = visualMapMultiTargetView.getVisualMap()
7540
+ const visualMapMultiTargetInitialLines = visualMapMultiTargetView.listLines().map(line => line.item.id)
7541
+ check('visualMap:不写 target 时跳过缺字段的节点选展示值域,门控仍覆盖全部热力目标',
7542
+ visualMapMultiTargetInitial?.target === 'line' && visualMapMultiTargetInitial.domain.join(',') === '1,10' &&
7543
+ visualMapMultiTargetView.listPoints().length === 2 &&
7544
+ visualMapMultiTargetInitialLines.join(',') === 'vmmt-solo-high,vmmt-ch-high',
7545
+ JSON.stringify({ readout: visualMapMultiTargetInitial && { target: visualMapMultiTargetInitial.target, domain: visualMapMultiTargetInitial.domain }, points: visualMapMultiTargetView.listPoints().map(point => point.item.id), lines: visualMapMultiTargetInitialLines }))
7546
+ visualMapMultiTargetView.updatePoint('vmmt-low', { value: 1 })
7547
+ visualMapMultiTargetView.updatePoint('vmmt-high', { value: 10 })
7548
+ await settle()
7549
+ const visualMapMultiTargetPoints = visualMapMultiTargetView.listPoints().map(point => point.item.id)
7550
+ const visualMapMultiTargetLines = visualMapMultiTargetView.listLines().map(line => line.item.id)
7551
+ check('visualMap:多目标 network 同时隐藏落选节点、独立线路与整条落选通道',
7552
+ visualMapMultiTargetView.getVisualMap()?.target === 'point' &&
7553
+ visualMapMultiTargetPoints.join(',') === 'vmmt-high' &&
7554
+ visualMapMultiTargetLines.join(',') === 'vmmt-solo-high,vmmt-ch-high' &&
7555
+ visualMapMultiTargetView.getData().points.length === 2 && visualMapMultiTargetView.getData().lines.length === 4 &&
7556
+ visualMapMultiTargetView.getChannels().length === 2,
7557
+ JSON.stringify({ target: visualMapMultiTargetView.getVisualMap()?.target, points: visualMapMultiTargetPoints, lines: visualMapMultiTargetLines }))
7558
+ visualMapMultiTargetView.updateOptions({ visualMap: { ...visualMapMultiTargetConfig, selected: [0, 1] } })
7559
+ await settle()
7560
+ check('visualMap:多目标恢复全选后节点、独立线路与通道成员全部回到画布',
7561
+ visualMapMultiTargetView.listPoints().length === 2 && visualMapMultiTargetView.listLines().length === 4,
7562
+ JSON.stringify({ points: visualMapMultiTargetView.listPoints().map(point => point.item.id), lines: visualMapMultiTargetView.listLines().map(line => line.item.id) }))
7563
+ visualMapMultiTargetView.destroy()
7564
+ visualMapMultiTargetHost.remove()
7565
+
6140
7566
  lineOnlyView.destroy()
6141
7567
  lineOnlyHost.remove()
6142
7568
  splitView.destroy()