Skip to content

架构总览 ​

这页回答:编辑器由哪些部分组成、样式数据从装载到渲染流经哪些环节、每个关键机制为什么这样设计又落在哪个文件。读完你应当在「不改错层」的前提下定位任何一处改动。架构规则的权威原文在 editor/docs/refactor/ARCHITECTURE-RULES.md,状态细则在 editor/docs/refactor/DEVELOPMENT-GUIDE.md,功能面的逐域导览见功能全景。代码规模:src/app 下约 250 个 TS 文件、7.7 万行;哪里胖、怎么瘦见 editor/docs/refactor/SIMPLIFICATION-PLAN.md。

分层与依赖方向 ​

全仓就四层,依赖只许向下:

流程图

依赖方向由 editor/tests/architecture.test.ts 机器固化,测试红即禁止合入。它管四件事:shared 到 core 为零、shared 到 features 为零、core 到 features 为零、import 图无环(值导入口径,type-only 回边豁免);同时堵住裸目录导入与动态 import 两类逃逸写法。

为什么这么较真:分层违规是渐进发生的,评审肉眼盯不住。让「改动的爆炸半径」可推算才是目的,改 shared 波及全仓但 shared 自身无依赖、改 core 不碰页面、features 之间互不可见。需要双向引用时不放松方向,而是把共享物抽成叶模块,已有案例:zoom-case-hoist、project-format.model、source-layer-meta、layer-list-fns。

各目录的一句话职责契约与扩展接入点,规则文档 §1.1 有全量表,本站目录结构与职责是其导航版。

四文档模型 ​

编辑器同时维护四份文档,每份有自己的 store、写通道与生命周期。这是理解本项目的钥匙:

流程图

四份文档的分工:

文档Store撤销栈持久化
StyleDocumentcore/style-store.service.ts入栈(immer patches)项目文件加草稿
ms-config 文档core/config/config-store.service.ts不入栈项目文件加草稿
FormDoccore/form/form-store.service.ts不入栈不持久化,运行时派生
InteractionDocumentcore/interaction/interaction-store.service.ts不入栈随 config 走项目文件

为什么拆四份而不是一份大 store:样式文档必须保持 MapLibre 规范形态,可直接装载、可校验,界面意图(中文显示名之类)塞进去就会污染导出物;表单形态只是样式的一种投影,缓存它但绝不把它当第二真源。写通道差异是关键:StyleStore 一切修改走 update(入撤销栈、同步地图),ConfigStore 走 immer producer 加 signal set(无补丁、无地图同步),跨文档的联动写用 StyleStore 的 transact 合并为单条撤销。

服务版图:22 个 @Injectable 各管什么 ​

全 src 共 22 个 @Injectable(2026-10 实测:core 18、features 2、shared 2,漂移以代码为准),core 侧分四组:

  • 四个 Store(状态真源):StyleStore、ConfigStore、FormStore、InteractionStore。
  • 域服务(编排业务流):MapService(地图门面)、InteractionEngineService(交互引擎接线)、AutosaveService(草稿落盘)、ExpressionSessionService(构建器会话);另有 ConfigDivergenceController 是手工 new 的控制器,组合根持有,非 Injectable。
  • 资源与元数据服务:SpecService(spec 元数据,属性面板词表之源,静态对照表在 core/spec-tables.ts)、ImageRegistry、FontRegistry、DependencyService(依赖锁定图)、InspectionService、FeatureSelectionService。
  • 基建服务:UiService(纯 UI 状态总线)、DiagnosticsService(诊断汇流);features 侧有导入图层行选中态服务,shared 侧有 ContextMenuService 与 TreeFilterService 两个轻量服务。

Store 样板统一:private signal 加 asReadonly() 暴露加命名 mutator 方法,ImageRegistry 与 StyleStore 是两个参考实现。UiService 的存量裸 signal 是评审接受的例外(全量只读化收益低于改动面,新增 signal 时优先配 mutator)。

组合根与装载链 ​

app.component.ts 是唯一允许横切装配的地方,它做四类事:

  • 布局编排:三栏骨架按信号互斥切换(topbar、layer-list 或 print-panel、layer-editor 或 multi-merge 或 global-editor、map-canvas、debug-panel),全部模态宿主挂在模板尾部,开合由 ui.modal() 联合类型驱动。
  • 横切接线:构造函数里 this.map.setStyleGetter(() => this.store.style()) 打破 MapService 与 StyleStore 的注入循环;inject(InteractionEngineService) 完成交互引擎装配;SPEC_DOC_UI_SOURCES 注入缝把 features 层的 Global 面板元数据表供给 core 层的规范模态,core 不感知 features 的物理实现;另挂 window.__MAPSTUDIO__ 调试句柄供控制台直查 store、map、expression。
  • 全局快捷键与项目文件进出:⌘S 导出项目文件,文件选择与路径装载。
  • 装载链:应用首次进入的样式从哪来。流程是 autosave 草稿命中则弹「恢复未保存的草稿?」(恢复即样式、配置、交互、撤销历史齐载,跳过分叉裁决;放弃则清草稿、布防 suppressNextSave 后走默认链,否则默认样式会被写成新草稿,下次刷新再弹提示),无草稿则布防后静默装载 klokantech,失败逐级回退 demotiles 到内置离线演示样式。这张流程图在从零开始里。

装载落地的公共内核是 StyleStore.load(style, name):先 normalizeImportedStyle 做 Mapbox 到 MapLibre 归一化(core/style-import.ts),再把样式内的 legacy 约定键经 extractLegacyConfig 抽出灌入 ConfigStore(样式原文逐字节保留,尊重外来文件),随后 signal 置值、会话复位(清脏标记、历史栈、选择态)、map.loadStyle 整载。打开项目文件走 restoreProject:config 段经 ConfigStore.load 深校验,失败时中文 toast 并按「保留样式」降级从样式重建 config,不拒整档;装载成功后跑分叉检测(见下文)。

样式数据流:一次属性修改的旅程 ​

三条链先给全景:装载是低频整载(一次 map.loadStyle),编辑是高频增量(每次 applyDiff),导出是终检(净化加官方校验器)。编辑链是全项目最常走的一条路:

流程图

update() 内聚的步骤值得逐条记住,因为它是所有修改的唯一入口:空样式直接返回;assertEditable 受保护守卫(拒绝并 toast 指引);produce 产出双份补丁,零补丁直接返回(等值写入不入栈);coalesce 判定(事务进行中禁合并,合并会把跨文档 effects 挂到旧条目上);入栈或合并栈顶;diffStyles 结构 diff;signal 置值加 map.applyDiff。

读面没有对称的「读通道」,全部是 computed(layers、selectedLayer、basemapLayerIds、importsList 等),组件模板只读信号不自己算状态。diffStyles 与它的产物类型 StyleDiff 现在住在 core/map/style-diff.ts(2026-10 从 style-store.service.ts 拆出,138 行纯函数):层增删含插入锚、移动、逐层 paint 与 layout 的键级变更(仅同 type 层比对,visibility 单列)、filter 与 zoomRange 标志、根键标志(light、terrain、sky、glyphs、sprite、font-faces、projection)、sources 变更 id 清单。键值比较统一走 jsonEqualsLoose。单独成文件的理由写在文件头注释里:map 子树只需要这份类型与纯函数,不必反向依赖 store 本体。

地图同步与双引擎 ​

core/map/ 是地图运行时门面,map.service.ts 的 MapService 对上是唯一入口,内部按天然分段拆了协作单元:

文件职责
map.service.ts门面:生命周期、diff 下发、原生事件转译、相机与就绪态信号、投影基线锚
map-adapter.ts适配器缝:MapAdapter 接口加 makeMapAdapter 工厂(双引擎选择点)加 worker 接线
gissdk-map.adapter.tsGISSDK 引擎实现:SDK 建图后取原生 maplibre 实例,diff 与事件全走原生
style-diff.tsStyleDiff 类型与 diffStyles 结构 diff(纯函数)
map-diff.tsrAF 同帧 diff 合并(mergeDiff)、层序判定、失败聚合成单条诊断
map-diff-apply.tsapplyDiffNow 编排器:层增删改、移动、源装载、根下发、投影基线重建
map-capabilities.ts双引擎能力矩阵唯一事实源(人读视图与对账测试均从此派生)
map-query.ts、pick-color.ts视口要素查询与取色
map-debug-sync.tsDebug 面板的色盲模拟与语言地区预览同步
feature-ref.ts、feature-selection.service.ts纯数据 FeatureRef(MapLibre 运行时 Feature 对象不进 Store)与选中态
map-defaults.ts缺省相机与演示端点常量

同步策略是增量而非重载:编辑链每次走 applyDiff,同帧内多条 diff 先 mergeDiff 合并,rAF 边界统一由 applyDiffNow 下发。整样式 loadStyle 只发生在装载与热切换分支。失败处理遵循吞错语义:单条下发失败不中断批次,逐条记录,批末合并为一条 runtime 诊断上报并 console.warn。投影变更走显式 projection 标志驱动 setProjection,基线签名比对保留为样式域重建后的兜底。

双引擎的选择发生在 makeMapAdapter():宿主注入了 window.__MAPSTUDIO_GISSDK__ 运行时则用 GISSDK 适配器(ng-mfe Portal 场景),否则 maplibre 直挂(dev 默认),MapService 与上层组件零改动。新地图能力必须过三问门禁:双引擎是否都支持、不支持时降级面是什么、spec 是否有对应属性;对账测试强制「native API 消费是能力矩阵登记项的子集」。worker 接线分两路:dev 经 setWorkerUrl 指向 node_modules 原始文件;prod 由 vite 插件按同名同目录发射 worker 与 shared 两资产,恒以 .js 落盘(.mjs 的 MIME 兼容面依赖部署环境,ADR-09)。

撤销栈与会话状态 ​

撤销的存储单位是 UndoEntry:{ label, patches, inversePatches, at, effects? }。前四项来自 immer patch 模式;effects 是跨文档事务快照(configBefore 与 configAfter 两个 immer 冻结引用,零拷贝)。样式文档在一个编辑会话里的状态变迁如下:

流程图

撤销与重做的镜像内核是私有方法 shiftEntry(from):弹源栈条目、应用补丁(undo 走 inversePatches,redo 走 patches)、入对侧栈、coalesce 清零、replayEffects(undo 恢复 configBefore,redo 恢复 configAfter)、signal 置值、地图 diff。镜像收敛成一个方法是为了杜绝两份实现漂移。三种拒绝都「回栈顶不吞历史」:受保护、无样式本体、坏补丁。历史条目一旦被吞,jumpTo 与 Compare recent styles 的时间线就断了。

History 面板的时间线跳转 jumpTo(depth) 是连续 undo 或 redo 而非合并入栈;snapshotAtDepth 用 shared/style-snapshot.ts 的 reconstructSnapshot 重建任意历史时刻的样式(Compare recent styles 的数据源,只读不触碰栈)。已知例外:effects 仅会话内有效,档恢复后的撤销只回样式本体;redo 栈不持久化,重载后只能撤销到装载点。

跨文档事务:transact ​

有些操作天然要同时改样式与界面文档:删图层要清理 config 层段,改名要迁移 config 层段,导入底图要写 config 注册。这些写必须与样式变更同进退,也就是一条撤销。transact 把 fn 内的全部写入合并为单条撤销:

流程图

规则细节:嵌套调用并入外层;fn 抛错则事务不封口,退化为纯样式撤销;事务进行中禁用 coalesce(合并会把 effects 挂到旧条目上)。消费者如 removeLayers、renameLayer、importBasemapStyle。

双档模型与分叉裁决 ​

ms-config 文档的模型、codec 与 store 三件放在 core/config/:config.model.ts 是类型族(权威定义在 docs/spec/config-simplification/mapstudio-config.schema.json,TS 只是投影);config-codec.ts 是双向 codec 加 extractLegacyConfig;config-store.service.ts 是 Signals 单点持有者;layer-config-codec.ts 是图层配置值词表 v4 之家({form:…} 判别联合、停靠点、条件行)。

两档的一致性由 round-trip 不变量静态保证:applyConfigLayer(styleLayerToConfigLayer(layer), layer) 恒等于 layer(显式键集合与值恒等,单测锁定),不建运行时双向转换元数据。装载时的归属判定用 configSeed 身份比对:StyleStore 每次写 config 后记下文档引用,下次 load 时若档案是本通道灌入的则样式作用域段整建为抽取结果,外部载入的项目档只增量合入抽取键、绝不覆写用户档的偏好段。

分叉检测(config-divergence-controller.ts 加 config-divergence.ts)处理「两档都说自己对」的窗口:打开项目文件装载成功后,对样式中每层做派生段与存档段的 round-trip 比对,零分叉直通;有分叉升起「配置与样式不一致」弹层(不随遮罩或 Escape 关闭),默认「保留样式」(外部编辑样式是既有多数路径),「应用配置」是单条撤销的整层替换通道。草稿恢复跳过裁决:会话原样快照不该再被质疑。

背景底图与导入体系 ​

「把另一份样式文件并进当前世界」有两条平行通道(ADR-16):底图是单槽语义(整段替换、恒垫底),styleImports 是条目化多槽。底图的 interactivity 包装、作者标记、交付档解析这些纯函数,2026-10 起收在 core/config/basemap-delivery.ts(627 行),与 StyleStore 类零状态耦合,可独立单测;消费方有 StyleStore(装载与导出)、basemap-presets、import-layer-panel、import-config 与交互引擎。

  • 底图通道:importBasemapStyle 归一化后把 sources 与 layers 真实并入(id 冲突加 bm- 前缀重命名并改写引用),图层插到数组最前(渲染序最底),注册写 config.basemap{name, sourceIds, layerIds};removeBasemap 删注册层并回收只剩底图引用的 source(仍被其他图层或 terrain 引用的保留,防御式处理)。四张内置预设卡由 features/basemap-presets.ts 的 applyBasemapPreset 构造,同样先摘旧再导新。
  • styleImports 通道:importStyle 以 imp-N 条目注册进 config.styleImports,树底「导入」区按条目管理,removeImport 同口径回收。

两条通道共用的内核(文件内纯函数,均配单测):planSourceImportIds 与 planLayerImportIds 做改名预演加内容级去重(同 URL 或同 tiles 序列的 source 复用既有 id,不再重复网络请求);mergeImportIntoDraft 做 sources 合并加图层克隆改写;insertIndexAfterBasemap 决定新层插入位为最后一枚底图层之后(无条件插数组头会把新层压在不透明底图之下,地图上瞬时不可见)。

界面侧的四插槽驻留(top、middle、lower、bottom)是 config 域投影:config.basemap.membership 记录成员与槽位,features/basemap-slots.component.ts 消费;basemapIntact computed 判定底图完整性,残破态驱动重建而不是恢复可见。兜底 effect 在组合根:档记预设而样式无底图时按预设静默重应用,这是「档与样式不一致」的最后一道网。

表单域:FormDoc 派生缓存 ​

属性面板不直接读样式文档,读 FormStore 维护的 FormDoc。引入这层的动机是编辑体验:面板需要乐观更新(先改表单再序列化落库),需要把 spec 属性映射成中文标签与受控控件,两者都需要一个「表单视图」;让它成为纯派生缓存而非第二真源,就免除了双写一致性负担。

机制落在 core/form/:回灌恒从样式重导出,effect 只跟踪 style.layers 段,比较用 formDocEquals,结构相等时保持引用稳定防回环;FormDoc 不持久化,装载后由回灌 effect 重建(styleToFormDoc 对每个显式键 parsePropertyValue,写回经 formValueToStyleValue)。Global 面板的样式根投影四件套(atmosphere、light、projection、terrain 的 sync 文件)各自从样式根派生表单值,投影写回带合法性判定。UI-only 面板供料分档:snow、rain、picking 三面板在样式规范无对应根,值入 config.globals(键映射表在 config-codec 收口),其余面板一律从样式根重导出。

已知例外:FormStore 乐观更新存在失步缝隙,修复即行为变更须单独立项。

表达式内核 ​

core/expression/ 是「属性值可以是任意 MapLibre 表达式」这一复杂度的收口层:

流程图

内部恒走 AST 通道,旧直析实现已删,词表链恒等由 G8 守卫钉住。可视化侧的值模型是 EditableValue 判别联合:常量、zoom 插值、data 停靠、数据条件、交互包装、raw 兜底(不可可视化的表达式原样封存,round-trip 恒等)。表达式构建器 UI 跑在会话层上:每次写穿都从 FormDoc 现读现写,编辑后重投影防止形态翻转。机器门禁两道:运行语义等价对账(createExpression 求值,不止结构 round-trip)与 G8 词表链恒等守卫。

交互域 ​

附属交互的配置是 InteractionDocument(随 config 持久化),运行时是纯类 InteractionEngine(interaction-engine.ts:按文档解析事件到效果,依赖全注入,查询按事件记忆化、layer target 收窄),Angular 接线在 interaction-engine.service.ts(订阅 MapService 原语事件通道,装配点在组合根)。feature-state 写入只经 FeatureStateManager,失败计数并上报诊断;queryRenderedFeatures 必须带 layers 收窄。

编辑形态是「虚拟化」:addInteractionEvent 把基础层主色包装为 case 加 feature-state 表达式,不插入任何真实样式图层;树行识别与选中态走 attachedSelection({layerId, event} 结构化存储,不占真实图层选中态)与 core/interaction/interaction-badge.ts。底图 interactivity 是同机制的独立词表(feature-state 键 highlight 与 selected,不占用用户层词汇),派生条目只存在于引擎闭包,不写入 InteractionStore,持久化面零污染。

界面状态域:UiService 与计数通道 ​

core/ui.service.ts 是纯 UI 状态总线,与样式数据流彻底分离:左栏折叠与页签、分组折叠集、模态开合(modal 联合类型枚举全部弹窗,新弹窗等于联合加值、组合根模板分支、imports 三点改动)、顶栏下拉互斥、Toast、地图取色会话(token 隔离同屏多枚取色器)、Debug 预览选值。样式受保护的提示也单源在这里:notifyStyleLocked() 一个方法,11 个文件 17 处调用共用同一份文案与行为,替代了原先各调用点逐字复制的 toast。

跨组件的「打开面板加联动动作」一律走计数信号通道:sourcesAddRequest(sources 面板请求加源)、treeScrollTarget(树滚动定位)。计数而非布尔的语义是未挂载期请求不丢(计数累积)、消费即焚(读后归零)。Expression Builder 的整页请求是同思路的载荷设计:只定位(图层、段、属性、停靠下标),不携带模型快照,值由容器经 FormStore 现取;整页替换右列后 prop-editor 已不在 DOM,快照反而会过期。

诊断域 ​

core/diagnostics/diagnostics.service.ts 是分槽汇流点:setSource(key, diags) 按生产者 key 整写,同 key 整体替换、异 key 互不覆盖,all computed 合并全部槽。已登记的生产者:schema:json-draft(JSON 直编草稿的分级校验,L1 语法行列到 L3 属性级豁免)、semantic:config-sources(config 层段引用悬空 source 告警)、semantic 其他(feature-state 能力、源与 TileJSON)、runtime(setFeatureState 失败计数、style-diff 应用失败聚合)、export:style-spec(导出终检告警,不阻断导出)。

设计动机:五处散落校验各自 toast 会互相覆盖,分槽整写让错误面板成为唯一呈现面,清槽即注销。

请求流与降级链 ​

通道位置说明
样式 JSON 拉取core/style-fetch.ts(RxJS 管道,Promise 门面)http(s) 直接 fetch;本地磁盘路径经 dev 端点 /mock/local-style 读盘,404 回退直取
sprite 目录与图集core/sprite-fetch.ts后缀候选回退加判空收束,组件零 fetch
拾取与检测core/inspection.service.tsquerySourceFeatures 当前视口口径
瓦片与字体maplibre 实例自管dev 与 preview 由 /mock/* vite 插件供给,生产由 Rust server 承接

降级链:demotiles 失败回落内置离线演示样式;外域 glyphs 与 sprite 重试期有界定时重试(600ms 乘 8 次)不吞开关状态;导入通道丢弃的根键(Mapbox 专属根、sky、projection、terrain 不搬运)收敛为 console.debug,可观测但不打断并入语义。

持久化域 ​

core/persistence/ 管两条落盘通道。autosave(autosave.service.ts):style、config、interactions 任一变更后 800ms 防抖写 localStorage(键 ms-project-draft,上限 2MB,超限先去 history 段重试仍超则静默跳过);撤销历史随草稿落盘,其余运行时态不落。防死循环契约 suppressNextSave:程序化装载先布防一次,否则「放弃草稿」会被默认样式写回的草稿复活。pagehide 有强制落盘通道。

项目文件格式(project-format.ts 加 project-format.model.ts):v2 起是 style 加 config 双档信封,含 interactions 与 history 段;FormDoc 不再持久化。v1 旧档经 migrations.ts 的迁移注册表升级(禁止散落 if),未知 version 中文拒绝,宁响勿静默。

治理基件 ​

架构规则与门禁三位一体:editor/tests/architecture.test.ts(依赖方向、订阅纪律、import 环)、tests/app/spec-coverage.test.ts(spec 槽位、根属性、枚举值逐项对账,以 style-spec 的 latest.json 为真源)、tests/app/core/map/map-capabilities.test.ts(能力矩阵对账)。风险与豁免登记在 editor/docs/refactor/DEVELOPMENT-GUIDE.md 的风险台账(RK 编号),评审时逐条核对。这套机制如何随轮次运转见演进指南;哪里还有简化空间见 editor/docs/refactor/SIMPLIFICATION-PLAN.md(2026-10 全量读码的体检报告,本文提到的几处文件拆分就源于它)。