gis-view-webgl2 0.1.1 → 0.1.2

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 (34) hide show
  1. package/README.md +13 -1
  2. package/dist/gis-view.css +1 -1
  3. package/dist/index.cjs +1 -1
  4. package/dist/index.d.ts +255 -3
  5. package/dist/index.js +1 -1
  6. package/docs/AI/345/257/271/350/257/235/346/261/207/346/200/273.md +65 -0
  7. package/docs/GisView.md +1 -1
  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 +58 -0
  9. package/docs/demos/ChannelDemo.vue +566 -6
  10. package/docs/demos/CopyMergeDemo.vue +57 -6
  11. package/docs/demos/LabelDemo.vue +67 -11
  12. package/docs/demos/api-index.js +13 -3
  13. 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 +4 -2
  14. package/docs/format/04-/351/200/232/351/201/223/344/270/216/346/240/207/347/255/276.md +5 -4
  15. package/docs/manual/02-/345/235/220/346/240/207/344/270/216/346/225/260/346/215/256.md +7 -2
  16. package/docs/manual/03-/351/205/215/347/275/256/351/241/271.md +21 -5
  17. package/docs/manual/06-/346/225/260/346/215/256/344/270/216/345/234/272/346/231/257/346/233/264/346/226/260.md +100 -0
  18. 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 +38 -0
  19. package/docs/manual/08-/351/200/211/344/270/255/344/270/216/346/213/220/347/202/271.md +5 -5
  20. package/docs/manual/09-/351/200/232/351/201/223/344/270/216/345/244/232/345/233/236/347/272/277.md +58 -8
  21. package/docs/manual/10-/351/200/232/351/201/223/347/274/226/350/276/221/344/270/216/346/226/260/345/242/236.md +27 -2
  22. package/docs/manual/12-/346/240/207/347/255/276/344/270/216/350/243/205/351/245/260.md +45 -1
  23. package/docs/manual/16-/345/256/236/344/276/213/346/226/271/346/263/225/346/200/273/350/241/250.md +7 -1
  24. package/docs/manual/17-/345/217/202/346/225/260/351/200/237/346/237/245.md +8 -0
  25. package/docs/manual/18-/351/200/220/346/226/271/346/263/225/347/244/272/344/276/213.md +28 -0
  26. package/docs/manual/20-/346/200/247/350/203/275/344/270/216/345/244/247/346/225/260/346/215/256.md +3 -0
  27. 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 +1 -1
  28. package/docs/steps/README.md +8 -6
  29. package/docs/steps/selftest-demos.html +194 -29
  30. package/docs/steps/selftest-docs.html +7 -6
  31. package/docs/steps/selftest-features.html +213 -1
  32. package/docs/steps/selftest-labels.html +249 -4
  33. package/docs//346/216/245/345/217/243/345/215/217/350/256/256.md +17 -11
  34. package/package.json +2 -2
@@ -3044,7 +3044,8 @@
3044
3044
  channels: csmBuilt.channels,
3045
3045
  fitPadding: 60,
3046
3046
  showGrid: false,
3047
- // 点回线摊开选中集:点击选中显式打开
3047
+ // 点回线摊开选中集:先关拐点编辑,Ctrl 点击才能测「整批取反」;加点那一段再打开
3048
+ waypointEditable: false,
3048
3049
  features: { select: true }
3049
3050
  })
3050
3051
  await settle(140)
@@ -3085,9 +3086,99 @@
3085
3086
  // 通道外的线路没有分组可摊,照旧只选它自己
3086
3087
  await csmClick(csmView.project(100, 160))
3087
3088
  check('通道外的线路不受选中模式影响(还是只选自己)', csmIds() === 'csm-solo', csmIds())
3089
+ // Ctrl + 点击给通道回线加点:加完后的选中 / 高亮范围与普通单击共用 channelSelectMode
3090
+ csmView.updateOptions({ waypointEditable: true })
3091
+ await settle(60)
3092
+ const csmGroupSkeleton = () => csmView.getChannelGroups('csm-ch').find(group => String(group.id) === 'csm-g2')?.skeleton.length ?? 0
3093
+ const csmAddBefore = csmGroupSkeleton()
3094
+ csmView.updateOptions({ channelSelectMode: 'line' })
3095
+ await csmClick(csmView.project(50, 60), { ctrlKey: true })
3096
+ const csmLineAdd = csmView.getSelectionData()
3097
+ check('Ctrl + 点击加点(line):只高亮被加点的那一条回线', csmLineAdd.lineCount === 1 && csmGroupSkeleton() === csmAddBefore + 1 && csmIds() !== 'csm-2,csm-3' && csmIds() !== 'csm-1,csm-2,csm-3', JSON.stringify({ ids: csmIds(), waypoints: csmGroupSkeleton() }))
3098
+ csmView.updateOptions({ channelSelectMode: 'group' })
3099
+ await csmClick(csmView.project(100, 60), { ctrlKey: true })
3100
+ check('Ctrl + 点击加点(group):加完连所在分组一起高亮', csmIds() === 'csm-2,csm-3' && csmGroupSkeleton() === csmAddBefore + 2, JSON.stringify({ ids: csmIds(), waypoints: csmGroupSkeleton() }))
3101
+ csmView.updateOptions({ channelSelectMode: 'channel' })
3102
+ await csmClick(csmView.project(150, 60), { ctrlKey: true })
3103
+ check('Ctrl + 点击加点(channel):加完整条通道一起高亮', csmIds() === 'csm-1,csm-2,csm-3' && csmView.getSelectionData().channelCount === 1 && csmGroupSkeleton() === csmAddBefore + 3, JSON.stringify({ ids: csmIds(), waypoints: csmGroupSkeleton() }))
3088
3104
  csmView.destroy()
3089
3105
  csmHost.remove()
3090
3106
 
3107
+ /* 分组拐点 vertex 拖动:只平移当前分组同一 pathIndex 的顶点,其它拐点与分组不动 */
3108
+ const vwdHost = document.createElement('div')
3109
+ vwdHost.style.cssText = 'width:560px;height:360px'
3110
+ document.body.appendChild(vwdHost)
3111
+ const vwdView = createGisView(vwdHost, {
3112
+ points: [{ id: 'vwd-a', x: 0, y: 0 }, { id: 'vwd-b', x: 360, y: 0 }],
3113
+ lines: [
3114
+ { id: 'vwd-1', from: 'vwd-a', to: 'vwd-b' },
3115
+ { id: 'vwd-2', from: 'vwd-a', to: 'vwd-b' },
3116
+ { id: 'vwd-3', from: 'vwd-a', to: 'vwd-b' },
3117
+ { id: 'vwd-4', from: 'vwd-a', to: 'vwd-b' }
3118
+ ],
3119
+ channels: [{
3120
+ id: 'vwd-ch',
3121
+ from: 'vwd-a',
3122
+ to: 'vwd-b',
3123
+ groups: [
3124
+ { id: 'vwd-g1', lineIds: ['vwd-1', 'vwd-2'], path: [[100, 80], [260, 80]] },
3125
+ { id: 'vwd-g2', lineIds: ['vwd-3', 'vwd-4'], path: [[100, -80], [260, -80]], waypointOffsets: { '1': { x: 0, y: 6 } } }
3126
+ ]
3127
+ }],
3128
+ waypointEditable: true,
3129
+ waypointVisibility: 'always',
3130
+ channelWaypointDragMode: 'vertex',
3131
+ fitPadding: 70,
3132
+ showGrid: false
3133
+ })
3134
+ await settle(160)
3135
+ const vwdSend = (type, screen, extra = {}) => {
3136
+ const box = vwdView.canvas.getBoundingClientRect()
3137
+ vwdView.canvas.dispatchEvent(new PointerEvent(type, {
3138
+ bubbles: true,
3139
+ cancelable: true,
3140
+ pointerId: 79,
3141
+ pointerType: 'mouse',
3142
+ button: extra.button ?? 0,
3143
+ buttons: extra.buttons ?? (type === 'pointerup' ? 0 : 1),
3144
+ clientX: box.left + screen[0],
3145
+ clientY: box.top + screen[1],
3146
+ ...extra
3147
+ }))
3148
+ }
3149
+ const vwdBefore = vwdView.getChannelGroupWaypoints('vwd-ch', 'vwd-g1')
3150
+ const vwdOtherBefore = vwdView.getChannelGroupWaypoints('vwd-ch', 'vwd-g2')
3151
+ const vwdWorldStart = vwdBefore.lines[0].waypoints[0]
3152
+ const vwdWorldTarget = [vwdWorldStart[0] + 42, vwdWorldStart[1] + 30]
3153
+ const vwdStart = vwdView.project(vwdWorldStart[0], vwdWorldStart[1])
3154
+ const vwdTarget = vwdView.project(vwdWorldTarget[0], vwdWorldTarget[1])
3155
+ const vwdObjectOffset = vwdView.getChannelGroups('vwd-ch').find(group => String(group.id) === 'vwd-g2')?.waypointOffsets?.['1']
3156
+ const vwdObjectLane = vwdView.getChannels().find(group => String(group.id) === 'vwd-g2')?.offsets?.[0] ?? NaN
3157
+ check('分组拐点偏移:{ x, y } 声明归一成 [dx, dy] 并参与实际路由', Array.isArray(vwdObjectOffset) && Math.abs(vwdObjectOffset[0]) <= 1e-3 && Math.abs(vwdObjectOffset[1] - 6) <= 1e-3 && Math.abs(vwdOtherBefore.lines[0].waypoints[0][1] - (vwdOtherBefore.skeleton[1][1] + vwdObjectLane + 6)) <= 1e-3, JSON.stringify({ offset: vwdObjectOffset, lane: vwdObjectLane, skeleton: vwdOtherBefore.skeleton, line: vwdOtherBefore.lines[0].waypoints[0] }))
3158
+ vwdSend('pointermove', vwdStart, { buttons: 0 })
3159
+ vwdSend('pointerdown', vwdStart, { buttons: 1 })
3160
+ vwdSend('pointermove', [(vwdStart[0] + vwdTarget[0]) / 2, (vwdStart[1] + vwdTarget[1]) / 2], { buttons: 1 })
3161
+ vwdSend('pointermove', vwdTarget, { buttons: 1 })
3162
+ vwdSend('pointerup', vwdTarget, { buttons: 0 })
3163
+ await settle(180)
3164
+ const vwdAfter = vwdView.getChannelGroupWaypoints('vwd-ch', 'vwd-g1')
3165
+ const vwdOtherAfter = vwdView.getChannelGroupWaypoints('vwd-ch', 'vwd-g2')
3166
+ const vwdClose = (left, right, tolerance = 1e-3) => Math.abs(left - right) <= tolerance
3167
+ const vwdMoved = vwdAfter.lines[0].waypoints[0]
3168
+ const vwdPartnerBefore = vwdBefore.lines[1].waypoints[0]
3169
+ const vwdPartnerAfter = vwdAfter.lines[1].waypoints[0]
3170
+ const vwdAdjacent = vwdBefore.lines[0].waypoints[1]
3171
+ const vwdAdjacentAfter = vwdAfter.lines[0].waypoints[1]
3172
+ const vwdRoute = vwdView.getParallelGroups().find(group => String(group.group_id) === 'vwd-g1')
3173
+ const vwdOffset = vwdAfter.waypointOffsets['1']
3174
+ const vwdDeclared = vwdView.getOptions().channels[0].groups.find(group => String(group.id) === 'vwd-g1')?.waypointOffsets
3175
+ check('分组拐点 vertex:目标分组的同序号拐点按世界增量平移', vwdClose(vwdMoved[0] - vwdWorldStart[0], 42) && vwdClose(vwdMoved[1] - vwdWorldStart[1], 30) && vwdClose(vwdPartnerAfter[0] - vwdPartnerBefore[0], 42) && vwdClose(vwdPartnerAfter[1] - vwdPartnerBefore[1], 30), JSON.stringify({ moved: vwdMoved, partnerDelta: [vwdPartnerAfter[0] - vwdPartnerBefore[0], vwdPartnerAfter[1] - vwdPartnerBefore[1]] }))
3176
+ check('分组拐点 vertex:相邻拐点与其它分组一个都不动', vwdClose(vwdAdjacentAfter[0], vwdAdjacent[0]) && vwdClose(vwdAdjacentAfter[1], vwdAdjacent[1]) && JSON.stringify(vwdOtherAfter.lines.map(line => line.path)) === JSON.stringify(vwdOtherBefore.lines.map(line => line.path)), JSON.stringify({ adjacent: vwdAdjacentAfter, other: vwdOtherAfter.lines[0].waypoints }))
3177
+ check('分组拐点 vertex:只写 waypointOffsets,不改分组公共 path', vwdOffset && Object.keys(vwdAfter.waypointOffsets).join(',') === '1' && JSON.stringify(vwdAfter.path) === JSON.stringify(vwdBefore.path) && vwdDeclared && vwdClose(vwdDeclared['1']?.[0] ?? NaN, vwdOffset[0]) && vwdClose(vwdDeclared['1']?.[1] ?? NaN, vwdOffset[1]), JSON.stringify({ offsets: vwdAfter.waypointOffsets, declared: vwdDeclared, path: vwdAfter.path }))
3178
+ check('分组拐点 vertex:parallel_groups.group_route 带刚性偏移(导出不回到旧骨架)', Array.isArray(vwdOffset) && vwdClose(vwdRoute?.group_route?.[1]?.[0] ?? NaN, vwdBefore.skeleton[1][0] + vwdOffset[0]) && vwdClose(vwdRoute?.group_route?.[1]?.[1] ?? NaN, vwdBefore.skeleton[1][1] + vwdOffset[1]), JSON.stringify(vwdRoute?.group_route ?? null))
3179
+ vwdView.destroy()
3180
+ vwdHost.remove()
3181
+
3091
3182
  /* 删除(Delete 键):先出载荷说清「删什么 / 连带什么 / 能按哪些范围删」,确认之后才动数据 */
3092
3183
  const rmHost = document.createElement('div')
3093
3184
  rmHost.style.cssText = 'width:520px;height:320px'
@@ -4801,6 +4892,127 @@
4801
4892
  check('clearWebGLLayers 清空并同步 getOptions().webglLayers;requestRender 可链式调用',
4802
4893
  clearedLayers === 1 && draftView.getWebGLLayers().length === 0 && (draftView.getOptions().webglLayers ?? []).length === 0 && draftView.requestRender() === draftView,
4803
4894
  JSON.stringify({ cleared: clearedLayers, layers: draftView.getWebGLLayers().length, options: draftView.getOptions().webglLayers?.length }))
4895
+
4896
+ /* 隐藏 / 显示、节点拆分、通道变化对账:接口级自测 */
4897
+ const splitHost = document.createElement('div')
4898
+ splitHost.style.cssText = 'width:640px;height:380px'
4899
+ document.body.append(splitHost)
4900
+ const splitView = createGisView(splitHost, {
4901
+ points: [
4902
+ { id: 'sv-a', x: 0, y: 0, name: 'A' },
4903
+ { id: 'sv-b', x: 8, y: 0, name: 'B' },
4904
+ { id: 'sv-c', x: 0, y: 8, name: 'C' }
4905
+ ],
4906
+ lines: [
4907
+ { id: 'sv-ab', from: 'sv-a', to: 'sv-b', name: 'AB' },
4908
+ { id: 'sv-ab2', from: 'sv-a', to: 'sv-b', name: 'AB-2' },
4909
+ { id: 'sv-ac', from: 'sv-a', to: 'sv-c', name: 'AC' }
4910
+ ],
4911
+ channels: [{ id: 'sv-ch', from: 'sv-a', to: 'sv-b', lineIds: ['sv-ab', 'sv-ab2'] }],
4912
+ initialView: { scale: 1, center: [2, 2] },
4913
+ labelOptimize: false,
4914
+ showGrid: false,
4915
+ waypointEditable: true,
4916
+ history: { limit: 20 }
4917
+ })
4918
+ const splitOutcome = splitView.splitPoint('sv-a', [
4919
+ { x: -1, y: -1, name: 'A-左' },
4920
+ { x: 1, y: 1, name: 'A-右', lines: ['sv-ab'] }
4921
+ ])
4922
+ const splitData = splitView.getData()
4923
+ const splitB = splitData.points.find(point => point.name === 'A-右')
4924
+ const splitAb = splitData.lines.find(line => line.id === 'sv-ab')
4925
+ const splitAb2 = splitData.lines.find(line => line.id === 'sv-ab2')
4926
+ const splitAc = splitData.lines.find(line => line.id === 'sv-ac')
4927
+ const splitChannel = splitView.getOptions().channels.find(channel => channel.id === 'sv-ch')
4928
+ check('splitPoint:第一份复用原下标 / id,第二份追加并拿到新 id', splitOutcome.ok === true && splitOutcome.items.length === 2 && splitOutcome.indexes[0] === 0 && splitData.points.length === 4 && splitOutcome.pointIds[0] === 'sv-a' && splitOutcome.pointIds[1] !== 'sv-a', JSON.stringify({ ids: splitOutcome.pointIds, indexes: splitOutcome.indexes, points: splitData.points.length }))
4929
+ check('splitPoint:显式分给第二份的线路改绑,未分配的线路跟第一份', splitAb?.from === splitB?.id && splitAb2?.from === 'sv-a' && splitAc?.from === 'sv-a', JSON.stringify({ ab: splitAb?.from, ab2: splitAb2?.from, ac: splitAc?.from, b: splitB?.id }))
4930
+ check('splitPoint:通道没写 channels 时跟随第一条被分配过的成员回线', String(splitChannel?.from) === String(splitB?.id) && String(splitChannel?.to) === 'sv-b', JSON.stringify({ from: splitChannel?.from, to: splitChannel?.to, b: splitB?.id }))
4931
+ check('splitPoint:返回可直接提交的 changeSet 与 splitPoint 计数', splitOutcome.changes?.removedPoint?.id === 'sv-a' && splitOutcome.changes?.points?.length === 2 && splitOutcome.changes?.lines?.some(line => line.id === 'sv-ab') && splitOutcome.changes?.channels?.some(channel => channel.id === 'sv-ch') && splitOutcome.linesChanged === 3 && splitOutcome.channelsChanged === 1, JSON.stringify({ lines: splitOutcome.changes?.lines?.map(line => line.id), channels: splitOutcome.changes?.channels?.map(channel => channel.id) }))
4932
+ const changedAfterSplit = splitView.getChangedChannels().find(channel => channel.id === 'sv-ch')
4933
+ check('getChangedChannels:拆分后的端点变化入账,reason 带 endpoints', Boolean(changedAfterSplit) && changedAfterSplit.endpointsChanged && changedAfterSplit.reasons.includes('endpoints'), JSON.stringify(changedAfterSplit?.reasons))
4934
+ splitView.undo()
4935
+ const undoData = splitView.getData()
4936
+ check('splitPoint:撤销一步回到拆分前(节点、线路、通道端点全恢复)', undoData.points.length === 3 && undoData.lines.find(line => line.id === 'sv-ab')?.from === 'sv-a' && splitView.getOptions().channels.find(channel => channel.id === 'sv-ch')?.from === 'sv-a', JSON.stringify({ points: undoData.points.length, from: undoData.lines.find(line => line.id === 'sv-ab')?.from }))
4937
+ splitView.redo()
4938
+ check('splitPoint:重做恢复拆分结果', splitView.getData().points.length === 4 && splitView.getData().lines.find(line => line.id === 'sv-ab')?.from !== 'sv-a', JSON.stringify({ points: splitView.getData().points.length }))
4939
+ splitView.clearChangedChannels()
4940
+ check('clearChangedChannels:全部对账后最近变化为空', splitView.getChangedChannels().length === 0, JSON.stringify(splitView.getChangedChannels().map(item => item.id)))
4941
+ const addedSplitGroup = splitView.addChannelGroup('sv-ch', ['sv-ab2'])
4942
+ const fullGroupChange = splitView.getChangedChannels().find(channel => channel.id === 'sv-ch')
4943
+ const ignoredGroupChange = splitView.getChangedChannels({ ignoreGroups: true }).find(channel => channel.id === 'sv-ch')
4944
+ check('getChangedChannels({ ignoreGroups }):分组新增 / 删除不入账', addedSplitGroup.ok === true && fullGroupChange?.groupsChanged === true && !ignoredGroupChange, JSON.stringify({ added: addedSplitGroup.ok, full: fullGroupChange?.reasons, ignored: ignoredGroupChange?.reasons }))
4945
+ splitView.clearChangedChannels()
4946
+ if (addedSplitGroup.ok) {
4947
+ splitView.removeChannelGroup('sv-ch', addedSplitGroup.groupId)
4948
+ splitView.clearChangedChannels()
4949
+ }
4950
+ const splitGroup = splitView.getChannelGroups('sv-ch')[0]
4951
+ splitView.addChannelGroupWaypoint('sv-ch', splitGroup?.id)
4952
+ const waypointChange = splitView.getChangedChannels({ reasons: ['waypoints'] }).find(channel => channel.id === 'sv-ch')
4953
+ const ignoredWaypointChange = splitView.getChangedChannels({ ignoreGroups: true, reasons: ['waypoints'] }).find(channel => channel.id === 'sv-ch')
4954
+ check('getChangedChannels({ reasons: [waypoints] }):只返回拐点变化并收窄 reasons', Boolean(waypointChange) && waypointChange.waypointsChanged && waypointChange.reasons.length === 1 && waypointChange.reasons[0] === 'waypoints', JSON.stringify(waypointChange?.reasons))
4955
+ check('getChangedChannels({ ignoreGroups }):已有分组的拐点变化照常报', Boolean(ignoredWaypointChange) && ignoredWaypointChange.waypointsChanged && ignoredWaypointChange.reasons.length === 1 && ignoredWaypointChange.reasons[0] === 'waypoints', JSON.stringify(ignoredWaypointChange?.reasons))
4956
+ splitView.clearChangedChannels('sv-ch')
4957
+ splitView.clearChangedLines()
4958
+ splitView.updateLine('sv-ac', { name: 'AC-变更' })
4959
+ const fieldLineChange = splitView.getChangedLines({ reasons: ['fields'] }).find(line => line.id === 'sv-ac')
4960
+ check('getChangedLines({ reasons: [fields] }):线路字段变化入账', Boolean(fieldLineChange) && fieldLineChange.fields.includes('name') && fieldLineChange.reasons.length === 1 && fieldLineChange.reasons[0] === 'fields', JSON.stringify(fieldLineChange?.reasons))
4961
+ splitView.clearChangedLines('sv-ac')
4962
+ splitView.setLineWaypoints('sv-ac', [[4, 4]])
4963
+ const pathLineChange = splitView.getChangedLines({ reasons: ['path'] }).find(line => line.id === 'sv-ac')
4964
+ check('getChangedLines:path 变化独立记 path,读取不改基准', Boolean(pathLineChange) && pathLineChange.pathChanged && pathLineChange.reasons.includes('path') && splitView.getChangedLines({ reasons: ['path'] }).length === 1, JSON.stringify(pathLineChange?.reasons))
4965
+ splitView.clearChangedLines()
4966
+ const hideChannel = splitView.hideFeatures('channel', 'sv-ch')
4967
+ const hiddenChannelRead = splitView.getChannel('sv-ch')
4968
+ const hideChanged = splitView.getChangedChannels().find(channel => channel.id === 'sv-ch')
4969
+ 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 }))
4970
+ const showChannel = splitView.showFeatures('channel', 'sv-ch')
4971
+ 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 }))
4972
+ const generatedId = splitView.getData().points.find(point => point.name === 'A-右')?.id
4973
+ const hidePoint = splitView.hideFeatures('point', generatedId)
4974
+ const hideLine = splitView.hideFeatures('line', 'sv-ac')
4975
+ check('hideFeatures(point / line):只改 visible,数据不删', hidePoint.ok === true && hideLine.ok === true && splitView.getData().points.find(point => point.id === generatedId)?.visible === false && splitView.getData().lines.find(line => line.id === 'sv-ac')?.visible === false, JSON.stringify({ point: hidePoint.ok, line: hideLine.ok }))
4976
+ const hiddenLineChange = splitView.getChangedLines().find(line => line.id === 'sv-ac')
4977
+ check('getChangedLines:线路显隐变化入账', Boolean(hiddenLineChange) && hiddenLineChange.visibilityChanged && hiddenLineChange.reasons.includes('visibility'), JSON.stringify(hiddenLineChange?.reasons))
4978
+ splitView.showFeatures('point', generatedId)
4979
+ splitView.showFeatures('line', 'sv-ac')
4980
+ check('showFeatures(point / line):恢复后 visible 回到 true', splitView.getData().points.find(point => point.id === generatedId)?.visible === true && splitView.getData().lines.find(line => line.id === 'sv-ac')?.visible === true, '')
4981
+ splitView.clearChangedLines('sv-ac')
4982
+ splitView.updateLine('sv-ac', { name: 'AC-删除前' })
4983
+ const lineChangedBeforeDelete = splitView.getChangedLines().some(line => line.id === 'sv-ac')
4984
+ splitView.removeLine('sv-ac')
4985
+ const lineAfterDelete = splitView.getChangedLines().find(line => line.id === 'sv-ac')
4986
+ check('getChangedLines:线路删除同步清账,不再返回已删线路', lineChangedBeforeDelete && !lineAfterDelete, JSON.stringify({ before: lineChangedBeforeDelete, after: lineAfterDelete ?? null }))
4987
+ splitView.clearChangedChannels()
4988
+ splitView.updateChannel('sv-ch', { name: '待删通道' })
4989
+ const channelChangedBeforeDelete = splitView.getChangedChannels().some(channel => channel.id === 'sv-ch')
4990
+ splitView.removeChannel('sv-ch')
4991
+ const channelAfterDelete = splitView.getChangedChannels().find(channel => channel.id === 'sv-ch')
4992
+ check('getChangedChannels:通道删除同步清账,不再返回已删通道', channelChangedBeforeDelete && !channelAfterDelete, JSON.stringify({ before: channelChangedBeforeDelete, after: channelAfterDelete ?? null }))
4993
+ const lineOnlyHost = document.createElement('div')
4994
+ lineOnlyHost.style.cssText = 'width:480px;height:280px'
4995
+ document.body.append(lineOnlyHost)
4996
+ const lineOnlyView = createGisView(lineOnlyHost, {
4997
+ points: [
4998
+ { id: 'lo-a', x: 0, y: 0 },
4999
+ { id: 'lo-b', x: 8, y: 0 }
5000
+ ],
5001
+ lines: [{ id: 'lo-ab', from: 'lo-a', to: 'lo-b', name: 'AB' }],
5002
+ initialView: { scale: 1, center: [4, 0] },
5003
+ labelOptimize: false,
5004
+ showGrid: false
5005
+ })
5006
+ const lineOnlyInitial = lineOnlyView.getChangedLines().length === 0
5007
+ lineOnlyView.updateLine('lo-ab', { name: 'AB-变更' })
5008
+ const lineOnlyChange = lineOnlyView.getChangedLines({ reasons: ['fields'] }).find(line => line.id === 'lo-ab')
5009
+ lineOnlyView.removeLine('lo-ab')
5010
+ const lineOnlyAfterDelete = lineOnlyView.getChangedLines().find(line => line.id === 'lo-ab')
5011
+ check('getChangedLines:只有节点和线路时独立对账,删线路后同步清账', lineOnlyInitial && Boolean(lineOnlyChange) && lineOnlyChange.fields.includes('name') && !lineOnlyAfterDelete, JSON.stringify({ initial: lineOnlyInitial, change: lineOnlyChange?.reasons, after: lineOnlyAfterDelete ?? null }))
5012
+ lineOnlyView.destroy()
5013
+ lineOnlyHost.remove()
5014
+ splitView.destroy()
5015
+ splitHost.remove()
4804
5016
  draftView.destroy()
4805
5017
  draftHost.remove()
4806
5018
  auView.destroy()
@@ -411,10 +411,66 @@
411
411
  const quietState = addView.addLabel('point', 'p1', { content: '别拖我', position: 'bottom', draggable: false, editable: false })
412
412
  await settle(160)
413
413
  const quietNode = addHost.querySelector(`.gis-view__label[data-label-label='${quietState.labelId}']`)
414
- check('逐条写 draggable / editable: false 的标签单独把指针还回去', Boolean(quietNode) && !quietNode.classList.contains('gis-view__label--draggable') && !quietNode.classList.contains('gis-view__label--editable'), quietNode ? quietNode.className : '没有标签')
415
- addView.destroy()
416
- addHost.remove()
417
- // 15. 线路在通道里:parent.channel 说清「哪条通道的哪一回线」(id / groupId / 通道下标 / 分组里的位置)
414
+ check('逐条写 draggable / editable: false 的标签单独把指针还回去', Boolean(quietNode) && !quietNode.classList.contains('gis-view__label--draggable') && !quietNode.classList.contains('gis-view__label--editable'), quietNode ? quietNode.className : '没有标签')
415
+ addView.destroy()
416
+ addHost.remove()
417
+ // 14b. 数据里自带的 labels:单条 draggable / editable 覆盖全局,DOM 指针与事件也按单条走
418
+ const perItemHost = document.createElement('div')
419
+ perItemHost.style.cssText = 'width:360px;height:240px'
420
+ document.body.append(perItemHost)
421
+ const perItemClicks = []
422
+ const perItemDrags = []
423
+ const perItemView = createGisViewRaw(perItemHost, {
424
+ points: [{
425
+ id: 'per-item-point',
426
+ x: 0,
427
+ y: 0,
428
+ name: '逐条',
429
+ labels: [
430
+ { id: 'editable', content: 'A 可拖可点', position: 'top', gap: 24, draggable: true, editable: true },
431
+ { id: 'locked', content: 'B 只读', position: 'bottom', gap: 24, draggable: false, editable: false }
432
+ ]
433
+ }],
434
+ lines: [],
435
+ label: { enabled: true, content: '{name}', maxCount: 5, draggable: true, editable: true },
436
+ labelOptimize: false,
437
+ showGrid: false,
438
+ onLabelClick: payload => perItemClicks.push(payload),
439
+ onLabelDrag: payload => perItemDrags.push(payload)
440
+ })
441
+ await settle(240)
442
+ const perItemNode = id => perItemHost.querySelector(`.gis-view__label[data-label-kind='point'][data-label-label='${id}']`)
443
+ const itemEditable = perItemNode('editable')
444
+ const itemLocked = perItemNode('locked')
445
+ check('数据级逐条 labels:A 可拖可点,B 的 false 覆盖全局开关',
446
+ Boolean(itemEditable) && Boolean(itemLocked) &&
447
+ itemEditable.classList.contains('gis-view__label--draggable') && itemEditable.classList.contains('gis-view__label--editable') &&
448
+ !itemLocked.classList.contains('gis-view__label--draggable') && !itemLocked.classList.contains('gis-view__label--editable') &&
449
+ getComputedStyle(itemLocked).pointerEvents === 'none',
450
+ `A=${itemEditable?.className ?? '没有'} / B=${itemLocked?.className ?? '没有'}`)
451
+ if (itemEditable && itemLocked) {
452
+ const lockedAt = centerOf(itemLocked)
453
+ fire(itemLocked, 'pointerdown', lockedAt[0], lockedAt[1])
454
+ fire(window, 'pointerup', lockedAt[0], lockedAt[1])
455
+ fire(itemLocked, 'pointerdown', lockedAt[0], lockedAt[1])
456
+ fire(window, 'pointermove', lockedAt[0] + 36, lockedAt[1] + 18)
457
+ fire(window, 'pointerup', lockedAt[0] + 36, lockedAt[1] + 18)
458
+ await settle(120)
459
+ check('逐条只读标签点不发 labelClick、拖不写偏移', perItemClicks.length === 0 && perItemDrags.length === 0 && !perItemView.getLabelStates('point').some(state => state.labelId === 'locked'), `${perItemClicks.length} 次点击 / ${perItemDrags.length} 次拖动 / ${JSON.stringify(perItemView.getLabelStates('point'))}`)
460
+ const editableAt = centerOf(itemEditable)
461
+ fire(itemEditable, 'pointerdown', editableAt[0], editableAt[1])
462
+ fire(window, 'pointerup', editableAt[0], editableAt[1])
463
+ await settle(80)
464
+ fire(itemEditable, 'pointerdown', editableAt[0], editableAt[1])
465
+ fire(window, 'pointermove', editableAt[0] + 32, editableAt[1] + 20)
466
+ fire(window, 'pointerup', editableAt[0] + 32, editableAt[1] + 20)
467
+ await settle(160)
468
+ const editableState = perItemView.getLabelStates('point').find(state => state.labelId === 'editable')
469
+ check('逐条可交互标签照常点选与拖动,偏移按 labelId 分开记', perItemClicks.length === 1 && perItemClicks[0]?.labelId === 'editable' && perItemDrags.some(payload => payload.phase === 'end' && payload.labelId === 'editable') && Math.abs(editableState?.offset?.[0] - 32) <= 1 && Math.abs(editableState?.offset?.[1] - 20) <= 1, JSON.stringify({ clicks: perItemClicks.map(payload => payload.labelId), drag: perItemDrags.map(payload => `${payload.labelId}:${payload.phase}`), offset: editableState?.offset ?? null }))
470
+ }
471
+ perItemView.destroy()
472
+ perItemHost.remove()
473
+ // 15. 线路在通道里:parent.channel 说清「哪条通道的哪一回线」(id / groupId / 通道下标 / 分组里的位置)
418
474
  const chanHost = document.createElement('div')
419
475
  chanHost.style.cssText = 'width:320px;height:220px'
420
476
  document.body.append(chanHost)
@@ -1048,6 +1104,195 @@
1048
1104
  else {
1049
1105
  delete window.devicePixelRatio
1050
1106
  }
1107
+ // 26. HTML 标签:全局 / 逐条覆盖 / 模板转义 / 运行时切换 / 导出回灌 / 只改样式不重写内容
1108
+ const richHost = document.createElement('div')
1109
+ richHost.style.cssText = 'width:800px;height:500px'
1110
+ document.body.append(richHost)
1111
+ const richClicks = []
1112
+ const richPoints = [
1113
+ { id: 'rich-a', x: -180, y: 0, name: '<i>甲</i>', labels: [{ id: 'default', content: '<b class="rich">{name}</b>', html: true }] },
1114
+ { id: 'rich-b', x: 0, y: 0, name: '乙', labels: [{ id: 'default', content: '<b>{name}</b>', html: false }] },
1115
+ { id: 'rich-c', x: 180, y: 0, name: '丙' },
1116
+ { id: 'rich-d', x: 270, y: 100, name: '<i>丁</i>', labels: [
1117
+ { id: 'field', field: 'name', html: true, position: 'bottom' },
1118
+ { id: 'fallback', html: true, position: 'top' }
1119
+ ] }
1120
+ ]
1121
+ const richView = createGisView(richHost, {
1122
+ points: richPoints,
1123
+ lines: [{ id: 'rich-line', from: 'rich-a', to: 'rich-b', name: '<u>线</u>' }],
1124
+ initialView: { scale: 1, center: [0, 0] },
1125
+ label: { enabled: true, content: '<b>{name}</b>', html: false, offset: [12, 8], maxCount: 10, draggable: true, editable: true },
1126
+ lineLabel: { enabled: true, content: '<em>{name}</em>', html: true, maxCount: 10 },
1127
+ labelOptimize: false,
1128
+ showGrid: false,
1129
+ onLabelClick: payload => richClicks.push(payload)
1130
+ })
1131
+ await settle(240)
1132
+ const richPoint = index => richHost.querySelector(`.gis-view__label[data-label-kind='point'][data-label-index='${index}']`)
1133
+ const richLine = () => richHost.querySelector(`.gis-view__label[data-label-kind='line']`)
1134
+ const firstRich = richPoint(0)
1135
+ check('逐条 html: true:字符串按标记渲染,模板取值仍转义', firstRich?.querySelector('b.rich') !== null && firstRich.querySelector('i') === null && firstRich.textContent.trim() === '<i>甲</i>', firstRich ? `${firstRich.innerHTML} / ${firstRich.textContent}` : '没有标签')
1136
+ check('逐条 html: false:即使全局是 HTML,这一条也只显示字面标记', richPoint(1)?.querySelector('b') === null && richPoint(1)?.textContent.trim() === '<b>乙</b>', richPoint(1)?.textContent ?? '没有标签')
1137
+ check('全局 html: false:没有逐条覆盖的标签按纯文本渲染', richPoint(2)?.querySelector('b') === null && richPoint(2)?.textContent.trim() === '<b>丙</b>', richPoint(2)?.textContent ?? '没有标签')
1138
+ check('线路标签 html: true 同样渲染标记且转义模板取值', richLine()?.querySelector('em') !== null && richLine().querySelector('u') === null && richLine().textContent.trim() === '<u>线</u>', richLine()?.innerHTML ?? '没有线路标签')
1139
+ const richByLabelId = id => richHost.querySelector(`.gis-view__label[data-label-kind='point'][data-label-label='${id}']`)
1140
+ check('HTML 模式下 field / 默认名称只按纯文本转义', richByLabelId('field')?.querySelector('i') === null && richByLabelId('field')?.textContent.trim() === '<i>丁</i>' && richByLabelId('fallback')?.querySelector('i') === null && richByLabelId('fallback')?.textContent.trim() === '<i>丁</i>', `${richByLabelId('field')?.innerHTML ?? ''} | ${richByLabelId('fallback')?.innerHTML ?? ''}`)
1141
+ richView.updateOptions({ label: { enabled: true, content: '<b>{name}</b>', html: true, offset: [12, 8], maxCount: 10, draggable: true, editable: true } })
1142
+ await settle(200)
1143
+ check('全局 html: true:没有逐条覆盖的标签改成按 HTML 渲染', richPoint(2)?.querySelector('b') !== null && richPoint(2)?.textContent.trim() === '丙', richPoint(2)?.innerHTML ?? '没有标签')
1144
+ check('全局 HTML 打开时,逐条 html: false 仍压过全局', richPoint(1)?.querySelector('b') === null && richPoint(1)?.textContent.trim() === '<b>乙</b>', richPoint(1)?.textContent ?? '没有标签')
1145
+ richView.updateLabel('point', 'rich-c', { html: false })
1146
+ await settle(160)
1147
+ check('updateLabel html: false 把这一条从 HTML 切回纯文本', richPoint(2)?.querySelector('b') === null && richPoint(2)?.textContent.trim() === '<b>丙</b>', richPoint(2)?.textContent ?? '没有标签')
1148
+ const richOffsetWithState = richView.getLabels('point', 'rich-c')?.find(row => row.id === 'default')
1149
+ check('getLabels 的偏移不被运行时 html 状态洗成 [0, 0]', JSON.stringify(richOffsetWithState?.offset) === '[12,8]', JSON.stringify(richOffsetWithState?.offset ?? null))
1150
+ richView.updateLabel('point', 'rich-c', { html: true, offset: [23, 7] })
1151
+ await settle(160)
1152
+ check('updateLabel html: true 能再切回 HTML', richPoint(2)?.querySelector('b') !== null && richPoint(2)?.textContent.trim() === '丙', richPoint(2)?.innerHTML ?? '没有标签')
1153
+ const richOffsetRuntime = richView.getLabels('point', 'rich-c')?.find(row => row.id === 'default')
1154
+ check('getLabels 优先报运行时拖出来的偏移', JSON.stringify(richOffsetRuntime?.offset) === '[23,7]', JSON.stringify(richOffsetRuntime?.offset ?? null))
1155
+ const richClickNode = richPoint(0)
1156
+ const richClickAt = centerOf(richClickNode)
1157
+ fire(richClickNode, 'pointerdown', richClickAt[0], richClickAt[1])
1158
+ fire(window, 'pointerup', richClickAt[0], richClickAt[1])
1159
+ await settle(60)
1160
+ check('labelClick 载荷带 html 与原始标记(编辑面板不会把富文本洗成纯文本)', richClicks.length === 1 && richClicks[0].label?.html === true && richClicks[0].label?.content === richClickNode.innerHTML && richClicks[0].label.content.includes('&lt;i&gt;'), JSON.stringify(richClicks[0]?.label ?? null))
1161
+ const richBoldBefore = richPoint(0)?.querySelector('b')
1162
+ richView.updateLabel('point', 'rich-a', { style: { fontSize: '15px' } })
1163
+ await settle(160)
1164
+ check('只改样式不重写 HTML 内容(子节点身份保持不变)', richBoldBefore !== null && richPoint(0)?.querySelector('b') === richBoldBefore && getComputedStyle(richPoint(0)).fontSize === '15px', `${richPoint(0)?.innerHTML ?? ''} / ${richPoint(0) ? getComputedStyle(richPoint(0)).fontSize : ''}`)
1165
+ const richExport = richView.exportData({ format: 'input', parts: ['points'] })
1166
+ const exportedRichB = richExport.points?.find(row => row.id === 'rich-b')
1167
+ const exportedRichA = richExport.points?.find(row => row.id === 'rich-a')
1168
+ check('导出 input 保留逐条 html: true / false(导出再回灌不会改回全局语义)', exportedRichA?.labels?.find(row => row.id === 'default')?.html === true && exportedRichB?.labels?.find(row => row.id === 'default')?.html === false, JSON.stringify({ a: exportedRichA?.labels, b: exportedRichB?.labels }))
1169
+ const reRichHost = document.createElement('div')
1170
+ reRichHost.style.cssText = 'width:800px;height:500px'
1171
+ document.body.append(reRichHost)
1172
+ const reRichView = createGisView(reRichHost, {
1173
+ points: richExport.points,
1174
+ lines: [],
1175
+ initialView: { scale: 1, center: [0, 0] },
1176
+ label: { enabled: true, content: '<b>{name}</b>', html: true, maxCount: 10 },
1177
+ labelOptimize: false,
1178
+ showGrid: false
1179
+ })
1180
+ await settle(240)
1181
+ const reRichPoint = index => reRichHost.querySelector(`.gis-view__label[data-label-kind='point'][data-label-index='${index}']`)
1182
+ check('回灌后逐条 html: false 仍是纯文本,html: true 仍是富文本', reRichPoint(1)?.querySelector('b') === null && reRichPoint(1)?.textContent.trim() === '<b>乙</b>' && reRichPoint(0)?.querySelector('b.rich') !== null, `${reRichPoint(0)?.innerHTML ?? ''} | ${reRichPoint(1)?.innerHTML ?? ''}`)
1183
+ // 27. 同内容标签共用一次量测:120 条 HTML 标签首轮排版不应逐条读布局
1184
+ const batchLabelHost = document.createElement('div')
1185
+ batchLabelHost.id = 'label-batch-host'
1186
+ batchLabelHost.style.cssText = 'width:800px;height:500px'
1187
+ document.body.append(batchLabelHost)
1188
+ const batchLabelPoints = []
1189
+ for (let row = 0; row < 10; row += 1) {
1190
+ for (let column = 0; column < 12; column += 1) {
1191
+ batchLabelPoints.push({ id: `batch-label-${row}-${column}`, x: (column - 5.5) * 60, y: (row - 4.5) * 40, name: '同名' })
1192
+ }
1193
+ }
1194
+ let batchLabelMeasures = 0
1195
+ const nativeLabelRect = Element.prototype.getBoundingClientRect
1196
+ Element.prototype.getBoundingClientRect = function (...args) {
1197
+ if (this.classList?.contains('gis-view__label') && this.closest?.('#label-batch-host')) {
1198
+ batchLabelMeasures += 1
1199
+ }
1200
+ return nativeLabelRect.apply(this, args)
1201
+ }
1202
+ let batchLabelView = null
1203
+ try {
1204
+ batchLabelView = createGisView(batchLabelHost, {
1205
+ points: batchLabelPoints,
1206
+ lines: [],
1207
+ initialView: { scale: 1, center: [0, 0] },
1208
+ label: { enabled: true, content: '<b>同名</b>', html: true, maxCount: 120, scanLimit: 0 },
1209
+ showGrid: false
1210
+ })
1211
+ await settle(300)
1212
+ }
1213
+ finally {
1214
+ Element.prototype.getBoundingClientRect = nativeLabelRect
1215
+ }
1216
+ check('120 条同内容 HTML 标签首次排版共用尺寸缓存(真实布局读取 ≤ 4 次)', batchLabelHost.querySelectorAll('.gis-view__label').length === 120 && batchLabelMeasures <= 4, `标签 ${batchLabelHost.querySelectorAll('.gis-view__label').length} 条 / 量测 ${batchLabelMeasures} 次`)
1217
+ batchLabelView?.destroy()
1218
+ batchLabelHost.remove()
1219
+ // 28. 高密度纯文本标签:默认 auto 在强制显示档改走单画布,HTML / 交互标签仍留 DOM
1220
+ const canvasLabelHost = document.createElement('div')
1221
+ canvasLabelHost.style.cssText = 'width:800px;height:500px'
1222
+ document.body.append(canvasLabelHost)
1223
+ const canvasLabelPoints = []
1224
+ for (let row = 0; row < 25; row += 1) {
1225
+ for (let column = 0; column < 44; column += 1) {
1226
+ canvasLabelPoints.push({
1227
+ id: `canvas-label-${row}-${column}`,
1228
+ x: (column - 21.5) * 15,
1229
+ y: (row - 12) * 17,
1230
+ name: `L${row * 44 + column}`
1231
+ })
1232
+ }
1233
+ }
1234
+ const canvasLabelView = createGisViewRaw(canvasLabelHost, {
1235
+ points: canvasLabelPoints,
1236
+ lines: [],
1237
+ initialView: { scale: 1, center: [0, 0] },
1238
+ label: { enabled: true, content: '{name}', hideOnInteract: false },
1239
+ labelOptimize: false,
1240
+ labelRender: 'auto',
1241
+ showGrid: false
1242
+ })
1243
+ await settle(600)
1244
+ const canvasLayer = canvasLabelHost.querySelector('.gis-view__label-layer--point')
1245
+ const labelCanvas = canvasLayer?.querySelector('.gis-view__label-canvas')
1246
+ const canvasCount = () => Number(labelCanvas?.dataset.labelCount || 0)
1247
+ const canvasDomCount = () => canvasLayer?.querySelectorAll('.gis-view__label').length ?? 0
1248
+ check('1100 条纯文本标签自动改走单画布(不再逐条建 div)', canvasCount() >= 1024 && canvasDomCount() === 0, `画布 ${canvasCount()} 条 / DOM ${canvasDomCount()} 条`)
1249
+ check('标签画布按视口分配了非零像素尺寸', Number(labelCanvas?.width) > 0 && Number(labelCanvas?.height) > 0 && parseFloat(labelCanvas.style.width) > 0 && parseFloat(labelCanvas.style.height) > 0, `${labelCanvas?.width ?? 0}×${labelCanvas?.height ?? 0} 像素 / ${labelCanvas?.style.width ?? ''}×${labelCanvas?.style.height ?? ''}`)
1250
+ const mixedCanvasPoints = canvasLabelPoints.map((point, index) => index === 0
1251
+ ? { ...point, labels: [{ id: 'rich', content: '<b>富</b>', html: true }] }
1252
+ : point)
1253
+ canvasLabelView.updateOptions({ points: mixedCanvasPoints })
1254
+ await settle(600)
1255
+ const mixedDomLabels = [...(canvasLayer?.querySelectorAll('.gis-view__label') ?? [])]
1256
+ check('混入数据级 html: true:纯文本仍进画布,HTML 单独保留 DOM', canvasCount() >= 1100 && mixedDomLabels.length === 1 && mixedDomLabels[0].querySelector('b') !== null, `画布 ${canvasCount()} 条 / DOM ${mixedDomLabels.length} 条 / ${mixedDomLabels[0]?.innerHTML ?? ''}`)
1257
+ canvasLabelView.updateOptions({ labelRender: 'dom' })
1258
+ await settle(600)
1259
+ check('labelRender: dom 强制回退逐条 DOM,并清空画布', canvasDomCount() >= 1100 && canvasCount() === 0, `DOM ${canvasDomCount()} 条 / 画布 ${canvasCount()} 条`)
1260
+ canvasLabelView.updateOptions({ labelRender: 'auto' })
1261
+ await settle(600)
1262
+ check('从 dom 档切回 auto:画布重新接管,上一轮 div 全部回收', canvasCount() >= 1100 && canvasDomCount() === 1, `画布 ${canvasCount()} 条 / DOM ${canvasDomCount()} 条`)
1263
+ const decodeSvg = (url) => {
1264
+ const comma = url.indexOf(',')
1265
+ const body = url.slice(comma + 1)
1266
+ if (url.slice(0, comma).includes('base64')) {
1267
+ const binary = atob(body)
1268
+ const bytes = Uint8Array.from(binary, char => char.charCodeAt(0))
1269
+ return new TextDecoder().decode(bytes)
1270
+ }
1271
+ return decodeURIComponent(body)
1272
+ }
1273
+ const exportedCanvasLabel = async (text = 'L550') => {
1274
+ const markup = decodeSvg(await canvasLabelView.exportImage({ type: 'svg', includeLabels: true, background: 'none' }))
1275
+ const doc = new DOMParser().parseFromString(markup, 'image/svg+xml')
1276
+ const texts = [...doc.querySelectorAll('text')].map(node => node.textContent)
1277
+ return { node: [...doc.querySelectorAll('text')].find(node => node.textContent === text) ?? null, texts }
1278
+ }
1279
+ const exportedBefore = (await exportedCanvasLabel()).node
1280
+ check('导出 SVG 包含画布档的纯文本标签', exportedBefore !== null, exportedBefore ? exportedBefore.outerHTML : '没有找到 L550')
1281
+ const anchorPoint = canvasLabelPoints[550]
1282
+ const anchorGap = Number(exportedBefore?.getAttribute('x')) - canvasLabelView.project(anchorPoint.x, anchorPoint.y)[0]
1283
+ const cameraBeforeCanvasZoom = canvasLabelView.getCamera()
1284
+ canvasLabelView.setCamera({ ...cameraBeforeCanvasZoom, scale: cameraBeforeCanvasZoom.scale * 2 })
1285
+ await settle(80)
1286
+ const exportedZoom = await exportedCanvasLabel()
1287
+ const exportedZoomed = exportedZoom.node
1288
+ const expectedZoomX = canvasLabelView.project(anchorPoint.x, anchorPoint.y)[0] + anchorGap
1289
+ check('Canvas 标签随缩放只推一次,不与导出坐标二次叠加', Number.isFinite(anchorGap) && exportedZoomed !== null && Math.abs(Number(exportedZoomed.getAttribute('x')) - expectedZoomX) <= 1, `期望 x=${expectedZoomX.toFixed(1)} / 实际 ${exportedZoomed?.getAttribute('x') ?? '没有标签'} / 文本 ${exportedZoom.texts.length} 条:${exportedZoom.texts.slice(0, 8).join(',')} / 画布 ${canvasCount()} / DOM ${canvasDomCount()} / 收起 ${canvasLayer?.classList.contains('gis-view__label-layer--hidden')} / 相机 ${JSON.stringify(canvasLabelView.getCamera())}`)
1290
+ canvasLabelView.destroy()
1291
+ canvasLabelHost.remove()
1292
+ richView.destroy()
1293
+ reRichView.destroy()
1294
+ richHost.remove()
1295
+ reRichHost.remove()
1051
1296
  multi.destroy()
1052
1297
  reView.destroy()
1053
1298
  multiHost.remove()