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
@@ -663,7 +663,7 @@
663
663
  for (let index = 0; index < anchorLines.length; index += 1) {
664
664
  anchorLines[index] = { id: `ax${index}`, start: [anchorRandom() * 4000, anchorRandom() * 2500], end: [anchorRandom() * 4000, anchorRandom() * 2500], name: `长线 ${index}` }
665
665
  }
666
- const anchorView = createGisView(anchorHost, { points: anchorPoints, lines: anchorLines, width: 900, height: 560, label: { content: '{name}', maxCount: 200 }, lineLabel: { maxCount: 60 } })
666
+ const anchorView = createGisView(anchorHost, { points: anchorPoints, lines: anchorLines, width: 900, height: 560, progressiveInit: false, labelOptimize: true, label: { content: '{name}', maxCount: 200 }, lineLabel: { maxCount: 60 } })
667
667
  await sleep(200)
668
668
  for (let step = 0; step < 6; step += 1) {
669
669
  anchorView.setCamera({ center: [1000 + step * 120, 700 + step * 60], scale: 1.4 })
@@ -673,15 +673,28 @@
673
673
  // 程序化相机运动还有 160ms 的轻量画法窗口,停稳后会补一张全质量帧并再收一次候选。
674
674
  // 先让这一帧落地,再开始计量,后面测到的才是稳态重排,而不是收尾那一帧的噪声。
675
675
  await sleep(800)
676
+ // 四万点 + 四万线的发散渲染可能比上面那一段更慢:没铺完时 refreshLabels 会主动等下一帧,
677
+ // 这时六轮 updateOptions 的读数都是 0,随后 updatePoint 正好撞上第一次真实排版,
678
+ // 会把整屏标签都算成「第一次出现」。先等到 isLayoutReady 再做稳态断言。
679
+ const anchorReadyDeadline = now() + 8000
680
+ while (!anchorView.isLayoutReady() && now() < anchorReadyDeadline) {
681
+ await sleep(16)
682
+ }
683
+ await new Promise(resolve => requestAnimationFrame(() => resolve()))
684
+ // 字体加载完成会推进 labelFontEpoch,让所有标签尺寸缓存按新字体重量一次。
685
+ // 这是真实且必要的一次失效,不能算进「稳态重排」;先等字体集合稳定,避免冷启动把
686
+ // 最后一轮定向更新的读数抬成整屏标签数。
687
+ await document.fonts?.ready
688
+ await sleep(50)
676
689
  const anchorStats = { reads: 0, duplicates: 0 }
677
- const anchorSeen = new Set()
678
- const anchorReads = []
679
- const anchorDuplicates = []
680
- const nativeAnchorRect = Element.prototype.getBoundingClientRect
681
- Element.prototype.getBoundingClientRect = function (...args) {
682
- if (this.classList?.contains('gis-view__label')) {
683
- anchorStats.reads += 1
684
- // 按标签身份(类型 + 行号 + 标签名)去重:标签节点是循环复用的,按节点去重会把「同一格换了条标签」误判成重复量
690
+ const anchorSeen = new Set()
691
+ const anchorReads = []
692
+ const anchorDuplicates = []
693
+ const nativeAnchorRect = Element.prototype.getBoundingClientRect
694
+ Element.prototype.getBoundingClientRect = function (...args) {
695
+ if (this.classList?.contains('gis-view__label')) {
696
+ anchorStats.reads += 1
697
+ // 按标签身份(类型 + 行号 + 标签名)去重:标签节点是循环复用的,按节点去重会把「同一格换了条标签」误判成重复量
685
698
  const anchorKey = `${this.dataset.labelKind}#${this.dataset.labelIndex}#${this.dataset.labelLabel}`
686
699
  if (anchorSeen.has(anchorKey)) {
687
700
  anchorStats.duplicates += 1
@@ -690,11 +703,11 @@
690
703
  }
691
704
  return nativeAnchorRect.apply(this, args)
692
705
  }
693
- const anchorRound = () => {
694
- anchorStats.reads = 0
695
- anchorStats.duplicates = 0
696
- anchorSeen.clear()
697
- }
706
+ const anchorRound = () => {
707
+ anchorStats.reads = 0
708
+ anchorStats.duplicates = 0
709
+ anchorSeen.clear()
710
+ }
698
711
  try {
699
712
  // 跑 6 轮:换锚点(anchorFallback)/ 沿线滑动(slideFallback)会让某一轮补画几十条
700
713
  // 从没出现过的标签 —— 第一次出现必须量一次尺寸,这一笔有限、下一轮起回零;
@@ -702,8 +715,8 @@
702
715
  for (let round = 0; round < 6; round += 1) {
703
716
  anchorRound()
704
717
  anchorView.updateOptions({ label: { content: '{name}', maxCount: 200 }, lineLabel: { maxCount: 60 } })
705
- anchorReads.push(anchorStats.reads)
706
- anchorDuplicates.push(anchorStats.duplicates)
718
+ anchorReads.push(anchorStats.reads)
719
+ anchorDuplicates.push(anchorStats.duplicates)
707
720
  }
708
721
  // 改一条视口里的标签(下标 = 76 行 × 400 列 + 150 → 世界坐标 1500, 760):这一轮同样不该整层重量
709
722
  anchorRound()
@@ -714,13 +727,13 @@
714
727
  finally {
715
728
  Element.prototype.getBoundingClientRect = nativeAnchorRect
716
729
  }
717
- diag('放大后连续整层重排 · 标签量测读数', `逐轮 ${anchorReads.join(' / ')} 次 · 同一轮里重复量 ${anchorDuplicates.join(' / ')} 条(按标签身份去重;旧写法第 1 轮 2662 次里有 1235 次是把同一条标签又量了一遍)`)
730
+ diag('放大后连续整层重排 · 标签量测读数', `逐轮 ${anchorReads.join(' / ')} 次 · 同一轮里重复量 ${anchorDuplicates.join(' / ')} 条(按标签身份去重;旧写法第 1 轮 2662 次里有 1235 次是把同一条标签又量了一遍)`)
718
731
  check('稳态重排(后 3 轮)一次布局都不读(避让盒全走算术)', anchorReads.slice(3, 6).every(value => value === 0), `第 4~6 轮 ${anchorReads.slice(3, 6).join(' / ')} 次`)
719
732
  // 换落点那条路补画的新标签都是「第一次出现」,各量一次尺寸就进缓存;整段合计要有限,
720
733
  // 真退回「每轮把所有标签都真量一遍」的老写法时这里会立刻爆掉(旧写法一轮 1299 ~ 2662 次)
721
734
  check('稳态重排里补画的新标签只量第一次出现的那一次(合计有限、之后回零)', anchorReads.slice(1, 6).reduce((sum, value) => sum + value, 0) <= 400, `第 2~6 轮合计 ${anchorReads.slice(1, 6).reduce((sum, value) => sum + value, 0)} 次(逐轮 ${anchorReads.slice(1, 6).join(' / ')})`)
722
735
  check('整层重排里没有哪条标签(按身份)被量第二次(避让盒不再退回真量一次)', anchorDuplicates.every(value => value === 0), `逐轮重复 ${anchorDuplicates.join(' / ')} 条`)
723
- check('改一条可见标签之后的定向重排也不读布局、不重复量', anchorDuplicates[6] === 0 && anchorReads[6] === 0, `读数 ${anchorReads[6]} 次 · 重复 ${anchorDuplicates[6]} 条`)
736
+ check('改一条可见标签之后的定向重排也不读布局、不重复量', anchorDuplicates[6] === 0 && anchorReads[6] === 0, `读数 ${anchorReads[6]} 次 · 重复 ${anchorDuplicates[6]} 条`)
724
737
  anchorView.destroy()
725
738
  anchorHost.remove()
726
739
  labelView.destroy()
@@ -1447,10 +1460,225 @@
1447
1460
  const densityAfterPoint = densityView.getStats().heatDensity
1448
1461
  check('热力图密度场:定向 updatePoint 改要素值后场跟着重烤(不再停在旧数据上)',
1449
1462
  densityAfterPoint !== densityAfterColor && densityAfterPoint.samples === densityAfterColor.samples)
1450
- diag('热力图密度场更新', `纯透明度 ${opacityUpdateMs.toFixed(2)} ms / 换色带 ${colorUpdateMs.toFixed(2)} ms;烘焙 ${densityAfterColor.ms.toFixed(1)} ms`)
1451
- densityView.destroy()
1452
- densityHost.remove()
1453
- heatFreshView.destroy()
1463
+ diag('热力图密度场更新', `纯透明度 ${opacityUpdateMs.toFixed(2)} ms / 换色带 ${colorUpdateMs.toFixed(2)} ms;烘焙 ${densityAfterColor.ms.toFixed(1)} ms`)
1464
+ densityView.destroy()
1465
+ densityHost.remove()
1466
+
1467
+ /* 密度场不能复用背景图片的四边形:图片之外的要素也必须按自己的世界范围铺场 */
1468
+ const densityBackgroundHost = document.createElement('div')
1469
+ densityBackgroundHost.style.cssText = 'width:640px;height:380px'
1470
+ document.body.append(densityBackgroundHost)
1471
+ const densityBackgroundSvg = `data:image/svg+xml,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="8" height="8"><rect width="8" height="8" fill="#ffffff"/></svg>')}`
1472
+ const densityBackgroundView = createGisView(densityBackgroundHost, {
1473
+ points: [
1474
+ { id: 'dbg-a', x: 400, y: 200, value: 100 },
1475
+ { id: 'dbg-b', x: 460, y: 200, value: 100 }
1476
+ ],
1477
+ lines: [],
1478
+ pointStyle: { opacity: 0 },
1479
+ heatmap: {
1480
+ enabled: true,
1481
+ target: 'point',
1482
+ field: 'value',
1483
+ domain: [0, 100],
1484
+ colors: ['#ff0000', '#ff0000'],
1485
+ opacity: 1,
1486
+ glow: false,
1487
+ density: { style: 'smooth', resolution: 96, radiusRatio: 0.12, threshold: 0, opacity: 1 }
1488
+ },
1489
+ background: { color: '#000000', image: { src: densityBackgroundSvg, bounds: [0, 0, 80, 80] } },
1490
+ width: 640,
1491
+ height: 380,
1492
+ quality: 'high',
1493
+ showGrid: false
1494
+ })
1495
+ densityBackgroundView.setCamera({ center: [430, 200], scale: 1 })
1496
+ await densityBackgroundView.whenRendered()
1497
+ // 等背景图片解码完成后再显式补一帧;静止画面不会自己继续画,不能先 sleep 再等 whenRendered
1498
+ await sleep(300)
1499
+ densityBackgroundView.refresh()
1500
+ await densityBackgroundView.whenRendered()
1501
+ const densityBackgroundCanvas = densityBackgroundHost.querySelector('canvas')
1502
+ const densityBackgroundGl = densityBackgroundCanvas.getContext('webgl2')
1503
+ const densityBackgroundPixels = new Uint8Array(densityBackgroundCanvas.width * densityBackgroundCanvas.height * 4)
1504
+ densityBackgroundGl.readPixels(0, 0, densityBackgroundCanvas.width, densityBackgroundCanvas.height, densityBackgroundGl.RGBA, densityBackgroundGl.UNSIGNED_BYTE, densityBackgroundPixels)
1505
+ let densityBackgroundWarm = 0
1506
+ for (let index = 0; index < densityBackgroundPixels.length; index += 4) {
1507
+ const red = densityBackgroundPixels[index]
1508
+ if (red > 20 && red > Math.max(densityBackgroundPixels[index + 1], densityBackgroundPixels[index + 2]) + 5) {
1509
+ densityBackgroundWarm += 1
1510
+ }
1511
+ }
1512
+ check('热力图密度场:有图片背景时仍按数据范围铺场,图片范围外不漏画',
1513
+ Boolean(densityBackgroundView.snapshotScene().backgroundImage) && densityBackgroundWarm > 20 && densityBackgroundView.getStats().heatDensity?.samples > 0,
1514
+ JSON.stringify({ warm: densityBackgroundWarm, density: densityBackgroundView.getStats().heatDensity, image: Boolean(densityBackgroundView.snapshotScene().backgroundImage) }))
1515
+ densityBackgroundView.destroy()
1516
+ densityBackgroundHost.remove()
1517
+
1518
+ /* 热力交互帧:线路光晕不消失,且合并成一笔绘制(不再退回「无光晕 + 细线」) */
1519
+ const heatMotionHost = document.createElement('div')
1520
+ heatMotionHost.style.cssText = 'width:640px;height:380px'
1521
+ document.body.append(heatMotionHost)
1522
+ heatMotionHost.scrollIntoView({ block: 'center', inline: 'nearest' })
1523
+ const heatMotionPoints = Array.from({ length: 12 }, (unused, index) => ({
1524
+ id: `hm-p${index}`,
1525
+ x: (index % 4) * 80,
1526
+ y: Math.floor(index / 4) * 90
1527
+ }))
1528
+ const heatMotionLines = Array.from({ length: 18 }, (unused, index) => ({
1529
+ id: `hm-l${index}`,
1530
+ from: heatMotionPoints[index % heatMotionPoints.length].id,
1531
+ to: heatMotionPoints[(index * 5 + 3) % heatMotionPoints.length].id,
1532
+ value: 30 + (index % 5) * 15
1533
+ }))
1534
+ const heatMotionGlow = { strength: 1, size: [20, 3], blend: 'normal' }
1535
+ const heatMotionHeatmap = {
1536
+ enabled: true,
1537
+ target: 'line',
1538
+ field: 'value',
1539
+ domain: [0, 100],
1540
+ colors: ['#ff2d2d', '#ff2d2d'],
1541
+ opacity: 1,
1542
+ density: false,
1543
+ glow: heatMotionGlow
1544
+ }
1545
+ const heatMotionView = createGisView(heatMotionHost, {
1546
+ points: heatMotionPoints,
1547
+ lines: heatMotionLines,
1548
+ lineStyle: { width: 1, opacity: 1 },
1549
+ heatmap: heatMotionHeatmap,
1550
+ background: '#05070b',
1551
+ width: 640,
1552
+ height: 380,
1553
+ quality: 'high',
1554
+ interactionNoMsaa: false,
1555
+ showGrid: false
1556
+ })
1557
+ await heatMotionView.whenRendered()
1558
+ await sleep(80)
1559
+ const heatMotionCanvas = heatMotionHost.querySelector('canvas')
1560
+ const heatMotionGl = heatMotionCanvas.getContext('webgl2')
1561
+ const readHeatMotionPixels = () => {
1562
+ const pixels = new Uint8Array(heatMotionCanvas.width * heatMotionCanvas.height * 4)
1563
+ heatMotionGl.readPixels(0, 0, heatMotionCanvas.width, heatMotionCanvas.height, heatMotionGl.RGBA, heatMotionGl.UNSIGNED_BYTE, pixels)
1564
+ return pixels
1565
+ }
1566
+ const summarizeHeatMotionPixels = (pixels) => {
1567
+ let warm = 0
1568
+ let intensity = 0
1569
+ for (let index = 0; index < pixels.length; index += 4) {
1570
+ const red = pixels[index]
1571
+ const green = pixels[index + 1]
1572
+ const blue = pixels[index + 2]
1573
+ const redness = Math.max(0, red - Math.max(green, blue))
1574
+ intensity += redness
1575
+ if (red > 10 && redness > 3) {
1576
+ warm += 1
1577
+ }
1578
+ }
1579
+ return { warm, intensity }
1580
+ }
1581
+ const readHeatMotionWarmPixels = () => summarizeHeatMotionPixels(readHeatMotionPixels())
1582
+ const heatMotionPointById = new Map(heatMotionPoints.map(point => [point.id, point]))
1583
+ const heatMotionMidpoints = heatMotionLines.map((line) => {
1584
+ const from = heatMotionPointById.get(line.from)
1585
+ const to = heatMotionPointById.get(line.to)
1586
+ return [(from.x + to.x) / 2, (from.y + to.y) / 2]
1587
+ })
1588
+ const heatMotionWarmAt = (pixels, screen) => {
1589
+ if (!screen) {
1590
+ return false
1591
+ }
1592
+ const scaleX = heatMotionCanvas.width / Math.max(heatMotionHost.clientWidth, 1)
1593
+ const scaleY = heatMotionCanvas.height / Math.max(heatMotionHost.clientHeight, 1)
1594
+ const centerX = Math.round(screen[0] * scaleX)
1595
+ const centerY = Math.round((heatMotionHost.clientHeight - screen[1]) * scaleY)
1596
+ for (let offsetY = -5; offsetY <= 5; offsetY += 1) {
1597
+ const y = centerY + offsetY
1598
+ if (y < 0 || y >= heatMotionCanvas.height) {
1599
+ continue
1600
+ }
1601
+ for (let offsetX = -5; offsetX <= 5; offsetX += 1) {
1602
+ const x = centerX + offsetX
1603
+ if (x < 0 || x >= heatMotionCanvas.width) {
1604
+ continue
1605
+ }
1606
+ const index = (y * heatMotionCanvas.width + x) * 4
1607
+ const red = pixels[index]
1608
+ if (red > 8 && red > Math.max(pixels[index + 1], pixels[index + 2]) + 2) {
1609
+ return true
1610
+ }
1611
+ }
1612
+ }
1613
+ return false
1614
+ }
1615
+ const heatMotionMissingLines = pixels => heatMotionMidpoints
1616
+ .map((midpoint, index) => (heatMotionWarmAt(pixels, heatMotionView.project(midpoint[0], midpoint[1])) ? null : index))
1617
+ .filter(index => index !== null)
1618
+ const heatMotionStart = heatMotionView.getCamera()
1619
+ heatMotionView.setCamera({ center: [heatMotionStart.center[0] + 2, heatMotionStart.center[1] + 1], scale: heatMotionStart.scale * 1.15 })
1620
+ await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))
1621
+ heatMotionView.refresh({ reflow: false })
1622
+ const heatMotionMovingPixels = readHeatMotionPixels()
1623
+ const heatMotionMovingWarm = summarizeHeatMotionPixels(heatMotionMovingPixels)
1624
+ const heatMotionMovingStats = heatMotionView.getStats()
1625
+ const heatMotionMovingMissing = heatMotionMissingLines(heatMotionMovingPixels)
1626
+ await sleep(360)
1627
+ heatMotionView.refresh({ reflow: false })
1628
+ const heatMotionStaticPixels = readHeatMotionPixels()
1629
+ const heatMotionStaticWarm = summarizeHeatMotionPixels(heatMotionStaticPixels)
1630
+ const heatMotionStaticStats = heatMotionView.getStats()
1631
+ const heatMotionStaticMissing = heatMotionMissingLines(heatMotionStaticPixels)
1632
+ check('热力图交互帧:逐条线路在相机运动与停稳帧都有热力(不是只统计整屏暖色)',
1633
+ heatMotionMovingMissing.length === 0 && heatMotionStaticMissing.length === 0,
1634
+ `运动缺失 ${heatMotionMovingMissing.join(',') || '无'} / 停稳缺失 ${heatMotionStaticMissing.join(',') || '无'}`)
1635
+ check('热力图交互帧:相机运动时线路光晕仍在(暖色像素与停稳帧相当)',
1636
+ heatMotionStaticWarm.warm > 0 && heatMotionMovingWarm.warm > 0 && heatMotionMovingWarm.warm / heatMotionStaticWarm.warm > 0.55 && heatMotionMovingWarm.warm / heatMotionStaticWarm.warm < 1.8 && heatMotionMovingWarm.intensity / heatMotionStaticWarm.intensity > 0.65 && heatMotionMovingWarm.intensity / heatMotionStaticWarm.intensity < 1.5,
1637
+ `停稳 ${heatMotionStaticWarm.warm} 像素 / 强度 ${heatMotionStaticWarm.intensity};运动 ${heatMotionMovingWarm.warm} 像素 / 强度 ${heatMotionMovingWarm.intensity}`)
1638
+ check('热力图交互帧:合并管线接管线路绘制,不再走会丢光晕的细线快路径,也不多出绘制批次',
1639
+ heatMotionMovingStats.thinLines === false && heatMotionMovingStats.drawCalls <= heatMotionStaticStats.drawCalls,
1640
+ `thinLines=${heatMotionMovingStats.thinLines} drawCalls ${heatMotionStaticStats.drawCalls} -> ${heatMotionMovingStats.drawCalls}`)
1641
+ heatMotionView.updateOptions({ heatmap: { ...heatMotionHeatmap, glow: false } })
1642
+ await sleep(40)
1643
+ heatMotionView.refresh({ reflow: false })
1644
+ const heatMotionNoGlowWarm = readHeatMotionWarmPixels()
1645
+ check('热力图交互帧:关掉光晕后暖色覆盖明显收窄(像素断言确实量到光晕)',
1646
+ heatMotionNoGlowWarm.warm > 0 && heatMotionNoGlowWarm.warm < heatMotionStaticWarm.warm * 0.8 && heatMotionNoGlowWarm.intensity < heatMotionStaticWarm.intensity * 0.8,
1647
+ `有光晕 ${heatMotionStaticWarm.warm} 像素 / 强度 ${heatMotionStaticWarm.intensity};无光晕 ${heatMotionNoGlowWarm.warm} 像素 / 强度 ${heatMotionNoGlowWarm.intensity}`)
1648
+ const heatMotionNoGlowStart = heatMotionView.getCamera()
1649
+ heatMotionView.setCamera({ center: [heatMotionNoGlowStart.center[0] + 2, heatMotionNoGlowStart.center[1] - 1], scale: heatMotionNoGlowStart.scale * 1.02 })
1650
+ await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))
1651
+ heatMotionView.refresh({ reflow: false })
1652
+ const heatMotionNoGlowMovingWarm = readHeatMotionWarmPixels()
1653
+ const heatMotionNoGlowMovingStats = heatMotionView.getStats()
1654
+ check('热力图交互帧:关光晕时细线快路径照常生效(说明上面确实走的是合并分支)',
1655
+ heatMotionNoGlowMovingStats.thinLines === true,
1656
+ `thinLines=${heatMotionNoGlowMovingStats.thinLines}`)
1657
+ // 深底色最常用的加法混合也走合并管线:glow.blend 一变,混合状态与预乘输出要同时切过去
1658
+ heatMotionView.updateOptions({ heatmap: { ...heatMotionHeatmap, glow: { ...heatMotionGlow, blend: 'add' } } })
1659
+ await sleep(360)
1660
+ heatMotionView.refresh({ reflow: false })
1661
+ const heatMotionAddStaticPixels = readHeatMotionPixels()
1662
+ const heatMotionAddStaticWarm = summarizeHeatMotionPixels(heatMotionAddStaticPixels)
1663
+ const heatMotionAddStart = heatMotionView.getCamera()
1664
+ heatMotionView.setCamera({ center: [heatMotionAddStart.center[0] - 2, heatMotionAddStart.center[1] + 1], scale: heatMotionAddStart.scale * 1.02 })
1665
+ await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))
1666
+ heatMotionView.refresh({ reflow: false })
1667
+ const heatMotionAddMovingPixels = readHeatMotionPixels()
1668
+ const heatMotionAddMovingWarm = summarizeHeatMotionPixels(heatMotionAddMovingPixels)
1669
+ const heatMotionAddMovingStats = heatMotionView.getStats()
1670
+ const heatMotionAddStaticMissing = heatMotionMissingLines(heatMotionAddStaticPixels)
1671
+ const heatMotionAddMovingMissing = heatMotionMissingLines(heatMotionAddMovingPixels)
1672
+ check('热力图交互帧:配置加法混合时仍走两遍光晕管线,逐条线路在停稳与运动帧都不缺失',
1673
+ heatMotionAddStaticWarm.warm > 0 && heatMotionAddMovingWarm.warm > 0 &&
1674
+ heatMotionAddStaticMissing.length === 0 && heatMotionAddMovingMissing.length === 0 &&
1675
+ heatMotionAddMovingStats.thinLines === false,
1676
+ `停稳缺失 ${heatMotionAddStaticMissing.join(',') || '无'} / 运动缺失 ${heatMotionAddMovingMissing.join(',') || '无'};停稳 ${heatMotionAddStaticWarm.warm} 像素,运动 ${heatMotionAddMovingWarm.warm} 像素;thinLines=${heatMotionAddMovingStats.thinLines}`)
1677
+ await sleep(360)
1678
+ heatMotionView.destroy()
1679
+ heatMotionHost.remove()
1680
+
1681
+ heatFreshView.destroy()
1454
1682
  heatFreshHost.remove()
1455
1683
  heatView.destroy()
1456
1684
  heatHost.remove()
@@ -2448,9 +2676,52 @@
2448
2676
  check('同一批点线回灌:换通道骨架时线路那一半不能沿用旧几何',
2449
2677
  JSON.stringify(guardChanged?.lines?.map(line => line.waypoints)) !== JSON.stringify(guardAfter?.lines?.map(line => line.waypoints)),
2450
2678
  JSON.stringify({ before: guardAfter?.lines?.map(line => line.waypoints), after: guardChanged?.lines?.map(line => line.waypoints) }))
2451
- big.updateOptions({ channels: [] })
2452
-
2453
- thinView.destroy()
2679
+ big.updateOptions({ channels: [] })
2680
+
2681
+ /* ---------------- visualMap:图例更新快路径 / 同签名零 DOM 写入 / 自动值域 ---------------- */
2682
+ const visualPerfPointCount = 12000
2683
+ const visualPerfPoints = new Array(visualPerfPointCount)
2684
+ for (let index = 0; index < visualPerfPointCount; index += 1) {
2685
+ visualPerfPoints[index] = { id: `vp${index}`, x: (index % 120) * 12, y: Math.floor(index / 120) * 12, value: index % 1000 }
2686
+ }
2687
+ const visualPerfHost = document.createElement('div')
2688
+ visualPerfHost.style.cssText = 'width:640px;height:380px'
2689
+ document.querySelector('#map').append(visualPerfHost)
2690
+ const visualPerfView = createGisView(visualPerfHost, {
2691
+ points: visualPerfPoints,
2692
+ heatmap: { field: 'value', target: 'point', density: false, glow: false, opacity: 1 },
2693
+ visualMap: { target: 'point', title: '性能图例', unit: 'kW', tickCount: 5 },
2694
+ width: 640,
2695
+ height: 380,
2696
+ quality: 'high',
2697
+ showGrid: false
2698
+ })
2699
+ await visualPerfView.whenRendered()
2700
+ await sleep(40)
2701
+ const visualPerfDomain = visualPerfView.getVisualMap()?.domain
2702
+ check('visualMap:一万二千点按当前数据自动取值域', visualPerfDomain?.[0] === 0 && visualPerfDomain?.[1] === 999, JSON.stringify(visualPerfDomain))
2703
+ const visualPerfElement = visualPerfView.getVisualMapElement()
2704
+ let visualPerfMutations = 0
2705
+ const visualPerfObserver = new MutationObserver(records => { visualPerfMutations += records.length })
2706
+ visualPerfObserver.observe(visualPerfElement, { subtree: true, childList: true, attributes: true, characterData: true })
2707
+ const visualPerfPatch = { target: 'point', title: '性能图例', unit: 'kW', tickCount: 5 }
2708
+ const visualPerfStarted = now()
2709
+ for (let round = 0; round < 50; round += 1) {
2710
+ visualPerfView.updateOptions({ visualMap: { ...visualPerfPatch } })
2711
+ }
2712
+ const visualPerfSyncMs = (now() - visualPerfStarted) / 50
2713
+ await sleep(80)
2714
+ visualPerfObserver.disconnect()
2715
+ check('visualMap:50 次同签名更新只合帧一次,DOM 写入为 0', visualPerfMutations === 0, `${visualPerfMutations} 条 mutation`)
2716
+ diag('visualMap:只改图例的同步开销(1.2 万点热力图)', `${visualPerfSyncMs.toFixed(4)} ms / 次(50 次)`)
2717
+ visualPerfView.updatePoint('vp9999', { value: 5000 })
2718
+ const visualPerfUpdatedDomain = visualPerfView.getVisualMap()?.domain
2719
+ check('visualMap:定向改数据后自动值域跟着当前数据走', visualPerfUpdatedDomain?.[1] === 5000, JSON.stringify(visualPerfUpdatedDomain))
2720
+ visualPerfView.updateOptions({ visualMap: null })
2721
+ visualPerfView.destroy()
2722
+ visualPerfHost.remove()
2723
+
2724
+ thinView.destroy()
2454
2725
  thinHost.remove()
2455
2726
  big.destroy()
2456
2727
  bigHost.remove()
@@ -907,16 +907,60 @@
907
907
  const spacingScreen1 = spacingView.project(60, 0)
908
908
  const spacingScreen = Math.hypot(spacingScreen1[0] - spacingScreen0[0], spacingScreen1[1] - spacingScreen0[1])
909
909
  check('适应节点间距:最近节点中心距达到 minDistance(CSS 像素)', Math.abs(spacingScreen - 24) < 0.1, String(spacingScreen))
910
+ const exactSpacingView = createGisView(host(640, 320), {
911
+ points: [{ id: 'e0', x: 0, y: 0, size: 10 }, { id: 'e1', x: 60, y: 0, size: 10 }],
912
+ fitSpacing: { minDistance: 24, maxZoom: 0 },
913
+ showGrid: false
914
+ })
915
+ await settle(100)
916
+ const exactGap = (() => {
917
+ const first = exactSpacingView.project(0, 0)
918
+ const second = exactSpacingView.project(60, 0)
919
+ return Math.hypot(second[0] - first[0], second[1] - first[1])
920
+ })()
921
+ check('适应节点间距:普通适应得到的间距更大时也会缩到指定 minDistance', Math.abs(exactGap - 24) < 0.1, JSON.stringify({ scale: exactSpacingView.getScale(), gap: exactGap }))
922
+ exactSpacingView.updateOptions({ fitSpacing: { minDistance: 12, maxZoom: 2 } })
923
+ exactSpacingView.fitView()
924
+ await settle(40)
925
+ const exactSmallGap = (() => {
926
+ const first = exactSpacingView.project(0, 0)
927
+ const second = exactSpacingView.project(60, 0)
928
+ return Math.hypot(second[0] - first[0], second[1] - first[1])
929
+ })()
930
+ check('适应节点间距:改小 minDistance 会实际缩小比例,maxZoom 不再截断', Math.abs(exactSmallGap - 12) < 0.1, JSON.stringify({ scale: exactSpacingView.getScale(), gap: exactSmallGap }))
931
+ exactSpacingView.destroy()
910
932
  spacingView.updateOptions({ fitSpacing: { minDistance: 24, maxZoom: 2 } })
911
933
  spacingView.fitView()
912
934
  await settle(60)
913
- const spacingCapped = spacingView.getCamera().scale
914
- check('适应节点间距:maxZoom 限制相对普通适应比例的上限', Math.abs(spacingCapped - plainFit * 2) < 1e-6, `${plainFit} → ${spacingCapped}`)
935
+ const spacingMaxZoomIgnored = spacingView.getCamera().scale
936
+ check('适应节点间距:maxZoom 兼容保留,不截断精确的 minDistance', Math.abs(spacingMaxZoomIgnored - spacingFit) < 1e-9, `${spacingFit} → ${spacingMaxZoomIgnored}`)
915
937
  spacingView.updateOptions({ fitSpacing: false })
916
938
  spacingView.fitView()
917
939
  await settle(60)
918
940
  const spacingOff = spacingView.getCamera()
919
- check('适应节点间距:关闭后恢复普通适应比例与包围盒中心', Math.abs(spacingOff.scale - plainFit) < 1e-9 && Math.abs(spacingOff.center[0] - 5000) < 0.5, `${spacingCapped} → ${spacingOff.scale} / ${JSON.stringify(spacingOff.center)}`)
941
+ check('适应节点间距:关闭后恢复普通适应比例与包围盒中心', Math.abs(spacingOff.scale - plainFit) < 1e-9 && Math.abs(spacingOff.center[0] - 5000) < 0.5, `${spacingMaxZoomIgnored} → ${spacingOff.scale} / ${JSON.stringify(spacingOff.center)}`)
942
+ const selectedSpacing = fitSpacingBaseView.getPointsSpacing(['f1', { index: 3 }, 'f1'])
943
+ const selectedSpacingPx = 140 * spacingScale
944
+ check('getPointsSpacing:只算指定节点,引用去重并按当前比例返回 CSS 像素',
945
+ selectedSpacing.count === 2 && Math.abs(selectedSpacing.minDistance - selectedSpacingPx) < 1e-6 && Math.abs(selectedSpacing.maxDistance - selectedSpacingPx) < 1e-6 && selectedSpacing.nearest?.from.id === 'f1' && selectedSpacing.nearest?.to.id === 'f2',
946
+ JSON.stringify(selectedSpacing))
947
+ const emptySpacing = fitSpacingBaseView.getPointsSpacing([])
948
+ const singleSpacing = fitSpacingBaseView.getPointsSpacing('f1')
949
+ check('getPointsSpacing:空引用与单节点返回稳定空读数', emptySpacing.count === 0 && emptySpacing.minDistance === null && emptySpacing.maxDistance === null && singleSpacing.count === 1 && singleSpacing.minDistance === null && singleSpacing.maxDistance === null,
950
+ JSON.stringify({ emptySpacing, singleSpacing }))
951
+ const hiddenGroupSpacingView = createGisView(host(640, 320), {
952
+ points: [{ id: 'h0', x: 0, y: 0, size: 10 }, { id: 'h1', x: 40, y: 0, size: 10 }],
953
+ showGrid: false
954
+ })
955
+ await settle(100)
956
+ hiddenGroupSpacingView.hideFeatures('point', 'h1')
957
+ await settle(40)
958
+ const hiddenVisibleSpacing = hiddenGroupSpacingView.getPointSpacing()
959
+ const hiddenGroupSpacing = hiddenGroupSpacingView.getPointsSpacing(['h0', 'h1'])
960
+ check('getPointsSpacing:显式传入的隐藏节点仍参与统计(与 getPointSpacing 的可见集合分开)',
961
+ hiddenVisibleSpacing.count === 1 && hiddenGroupSpacing.count === 2 && Math.abs(hiddenGroupSpacing.minDistance - 40 * hiddenGroupSpacingView.getScale()) < 1e-6,
962
+ JSON.stringify({ visible: hiddenVisibleSpacing.count, group: hiddenGroupSpacing }))
963
+ hiddenGroupSpacingView.destroy()
920
964
  /* maxDistance 反过来压住稀疏数据:最远节点在屏幕上不超过给定像素,避免少量场站回显时整幅放大 */
921
965
  const sparseSpacingView = createGisView(host(640, 320), {
922
966
  points: [{ id: 's0', x: 0, y: 0, size: 10 }, { id: 's1', x: 100, y: 0, size: 10 }, { id: 's2', x: 0, y: 80, size: 10 }],
@@ -958,6 +1002,20 @@
958
1002
  Math.abs(gridCenter[0] - 5000) <= 0.5 && Math.abs(gridCenter[1] - 5000) <= 0.5,
959
1003
  JSON.stringify(gridCenter))
960
1004
  gridSpacingView.destroy()
1005
+ /* 默认 maxScale 不能提前截断 minDistance:经纬度这类小量纲数据需要的比例会超过旧缺省 20000 */
1006
+ const fineSpacingView = createGisView(host(640, 320), {
1007
+ points: [{ id: 'fine0', x: 0, y: 0, size: 10 }, { id: 'fine1', x: 0.01, y: 0, size: 10 }],
1008
+ fitSpacing: { minDistance: 1000 },
1009
+ showGrid: false
1010
+ })
1011
+ await settle(140)
1012
+ const fineScreen0 = fineSpacingView.project(0, 0)
1013
+ const fineScreen1 = fineSpacingView.project(0.01, 0)
1014
+ const fineGap = Math.hypot(fineScreen1[0] - fineScreen0[0], fineScreen1[1] - fineScreen0[1])
1015
+ check('适应节点间距:默认 maxScale 不再把 minDistance 截在旧上限,目标 1000px 能落到位',
1016
+ fineSpacingView.getScale() > 20000 && Math.abs(fineGap - 1000) < 0.2,
1017
+ JSON.stringify({ scale: fineSpacingView.getScale(), gap: fineGap }))
1018
+ fineSpacingView.destroy()
961
1019
  fitSpacingBaseView.destroy()
962
1020
  spacingView.destroy()
963
1021
  }