Skip to content

fix(plugin-map): 首帧相机取景到查询记录集,经度取短弧 (#4941) - #5000

Merged
yinlianghui merged 1 commit into
mainfrom
claude/issue-4941-map-fitbounds
Aug 17, 2026
Merged

fix(plugin-map): 首帧相机取景到查询记录集,经度取短弧 (#4941)#5000
yinlianghui merged 1 commit into
mainfrom
claude/issue-4941-map-fitbounds

Conversation

@yinlianghui

@yinlianghuiyinlianghui commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator

Fixes#4941

前提复核(先证伪,再动手)

卡面两半,一半实测证实、一半重新归因。基线 origin/main 实 sha 279fb139d(派发时刻的 1f4504bfa 之后又进了两个 PR)。

「有数据的视图首开空视口」——证实,并量到了具体相机。 在 baseline worktree 上用探针跑真组件(showcase 形状的记录:location: { lat, lng },视图不声明任何 map 配置),MapGL 拿到的首帧相机是:

{"longitude":-98.2127,"latitude":38.9367,"zoom":10}

即堪萨斯州中部、zoom 10(该纬度上视口约 30km 宽),而最近的一条记录(Austin,-97.74/30.27)在约 950km 之外,其余在 1500–2500km 外 —— 首帧一个 marker 都不在视口里。机械成因是两处叠加:

  1. ObjectMap.tsxinitialViewState 里 zoom 从来不由数据推导,只写 mapConfig.zoom || 3;
  2. getMapConfig 的默认分支 —— 恰恰是「作者什么都没声明」时走到的那一支 —— 凭空造出 zoom: 10center: [0, 0]。伪造值在读取处与真声明无法区分,于是最需要取景的视图反而被这个默认值挡住了。

showcase task 的 map 视图正是这条路径:examples/app-showcase/src/ui/views/task.view.ts 的 map 视图只写了 type / data / columns,没有 map 配置块。

「markers 落在相邻世界副本」——按 showcase 数据这一半没能复现,重新归因。 平台 location 值是 { lat, lng } 且经度被 spec 约束在 -180, 180,showcase 的十个种子坐标全在 -122..-71;旧代码算出的中心 -98.2 与它们的经度差最大约 25 度。MapLibre 的 marker 换副本逻辑(smartWrap)只在 marker 与相机中心相差超过 180 度、且该 marker 投影落在画面外时才移动一个整圈 —— 这个条件在 showcase 数据上不成立。所以在这份数据上,「相邻副本」不是本卡代码算出来的。

但这条症状本身有真实的机械通路,而且就在同一段被删掉的算术里:旧的中心取 (min(lng) + max(lng)) / 2,把圆当成直线。跨反经线相距两度的两条记录(179 与 -179)会得到一个 358 度宽、中心恰在其对极点的包围盒 —— 相机与 marker 相差 180 度,smartWrap 的条件成立,marker 就真的会被放到相邻的世界副本里,并因为它按「离上一次屏幕位置最近」取副本而黏在那儿。报告者在 zoom 10 空视口上缩小观察时(demo 样式默认 renderWorldCopies,低 zoom 下世界横向重复,而 marker 只画一次)读成「记录待在另一个世界」也说得通。

结论:卡面要求的修法(经度归一 + fitBounds)两半都需要,并且都做了;只是「相邻副本」在 showcase 数据上是缩小观察时的读解,而不是这份数据触发的路径。

契约排查:相机声明位

  • @objectstack/specListViewSchemastrictObject,按可视化类型声明了 kanban / calendar / gantt / gallery / timeline / chart / tree 七个配置块,没有 map,也没有任何 center / zoom / camera 位。showcase 自己的注释已经记着这件事(「map needs a spec MapConfigSchema the ListViewSchema doesn't yet have」)。也就是说:对象列表视图的 map 目前无法声明相机(也无法声明字段映射)—— 这是本卡之外的契约缺口,已另立 finding map list views have no authorable config surface — ListViewSchema declares no map block, so marker titles fall back to a field name the object does not have #5001
  • objectui 侧:content/docs/plugins/plugin-map.mdx 明确文档化了 map 块里的 zoomcenter: [lat, lng],注册表也把 { name: 'map', type: 'object' } 声明为输入,本包内的 MapConfigSchema 校验它。所以插件层一个已发布的相机声明位。

据此:已声明的相机继续完全胜出,fitBounds 只在没有相机声明时执行,不新增任何配置键

改动

  • 新增 packages/plugin-map/src/camera.ts:纯推导。computeMarkerBounds 沿包含全部 marker 的最短弧给出包围盒 —— 取相邻经度之间最宽的空隙作为切缝,剩下的就是最短弧;跨反经线时东边界写成超过 180 的值,这正是 MapLibre 期望的写法(它自己的 adjustAntiMeridian 产出同一形状)。纬度按 Mercator 可用带钳到 85 度,免得极点记录把相机算成无穷。
  • ObjectMap.tsx:有记录且无相机声明时,initialViewState 交出 bounds + fitBoundsOptions(padding 48、maxZoom 12),由 MapLibre 在真实容器尺寸下完成 fit(bounds 覆盖构造期的 center/zoom;构造里 resize() 就在 fitBounds 之前)。没有再加命令式的 fit 调用:loading 门在每次取数期间把 MapGL 卸掉,数据一变它必然重挂,一次性的 initialViewState 因此总是对应手里的记录集,也就不会去拽用户已经平移过的相机。
  • getMapConfig 的默认分支不再合成相机,只留字段名默认值。空视口回退因此落回原本就写在那儿的 || 2(整个世界),而不是原点附近的一块海。
  • 只有可读的声明才算声明:center 写成 README 里那种 { lat, lng } 对象时,MapConfigSchema 已经会告警,此处不做任何迁就地把它当作未声明,视图照常取景 —— 不为坏形状发明第二套读法,也不让它换来一个空视口。
  • 记录坐标一律不挽救:越界经度是生产端缺陷,继续被拒绝并计入视图的 invalid coordinates 提示;上面的归一只是对已合法值做的相机算术。

验证

packages/plugin-map:7 个测试文件 43 个用例全绿;包内 tsc --noEmittsc -p tsconfig.test.json 通过;仓根 turbo run type-check --concurrency=2 81/81;check-control-bytes OK;改动文件 eslint 0 error。远端 20 个 check 全部 completed:18 success + 2 skipped(Test (coverage)dependabot,path/config 过滤),0 failure。

单测钉住:多点集、跨反经线集(179/-179 得到 179..181,而不是 -179..179)、单点与同址多点(零宽盒 + maxZoom)、空集(不 fit)、显式声明相机时不 fit、只声明一半时另一半继续推导、以及不可读声明仍取景。

反向验证(先书面预判,后跑;commit 后变异,git checkout -- 还原):

  1. 撤掉经度归一与最短弧(保留 fit)—— 预判 4 红且只红在绕圈用例上:实测正是那 4 个(fits across the antimeridian 收到 [[-179,-18],[179,-16]]),其余 14 绿。美国点集、单点、全球点集在这个变异下不会红 —— 不跨反经线时朴素极值与最短弧一致,所以绕圈用例是归一那一半唯一的守卫,这点如实写在测试注释里。
  2. 撤掉 fit 分支 —— 预判组件用例 4 红、camera.test.ts 全绿:实测一致(bounds 变 undefined)。纯推导测不到有没有人调用它,这正是另起组件级文件的原因:只有推导测试的话,把接线删掉仍然 100% 绿。
  3. 恢复被删掉的伪造默认值(zoom: 10, center: [0, 0])—— 这一条直接复现卡面症状。预判 5 红,实测 4 红,与预判不符的一条如实记录:still fits when the declared camera cannot be read 保持绿,因为那条 fixture 声明了 map 块,getMapConfig 在第二分支就返回了,根本走不到被变异的默认分支。变异的作用面恰好就是「无配置」这条路径 —— 也就是 showcase 与本卡所在的那条。

活体复现:没有做。 容器内没有起 objectstack showcase dev server 与浏览器(与并行 agent 共享一台机器,maplibre 还需要 WebGL)。上面 {"longitude":-98.2127,...} 是在 baseline worktree 里用真组件 + showcase 形状记录量到的,不是浏览器截图;「相邻世界副本」这一半没有活体验证,只有上面的代码级归因。

顺带一处必须改的测试

index.registration.test.tsx 把待测模块图的导入提到了模块作用域(AGENTS.md §9)。该文件单跑一直超时(baseline 上也是:tests 6.19s,预算 5s),只在同批别的测试文件先热了 transform 缓存时才绿 —— 本包新增测试文件后这个顺序依赖立刻断了。按 §9 的办法把成本挪进不受任何 test/hook 超时约束的 import 阶段,不调超时;现在它单跑也绿(import 6.14s / tests 55ms)。

相关 finding(本 PR 不修)

地图视图的初始相机从不由数据推导:zoom 来自 `getMapConfig` 的默认分支
——恰恰是「作者什么都没声明」时走到的那一支——它凭空造出 `zoom: 10` 与原点
center,而这个伪造值在读取处与真声明无法区分。于是未配置的对象列表视图把
横跨大陆的记录集开在约 30km 宽的视口里,首帧一个 marker 都看不到,只能手动
缩放平移去找(showcase `task` 地图视图的十个美国城市种子坐标跨约 4000km)。
现在相机是 marker 集的包围盒,以 `initialViewState.bounds` 交给 MapLibre,
在真实容器尺寸下完成 fit,带 padding 与城市级 zoom 上限(单条记录、或同址
多条记录得到零宽盒子;不设上限时它会回答一个屋顶级视角,而样式的瓦片远达
不到那一级)。
包围盒沿**包含全部 marker 的最短弧**度量,而不是取经度极值。把圆读成直线时,
跨反经线相距两度的两条记录(179 与 -179)会描述出一个 358 度宽、中心落在其
对极点的盒子;MapLibre 随后把 marker 放到离它们上一次屏幕位置最近的那个世界
副本里 —— 这正是「相机看着像取过景、画面却是空海、记录待在相邻世界副本」的
由来。
刻意不改的部分:不挽救记录坐标。平台的 `location` 值把经度约束在
[-180, 180],越界坐标是生产端缺陷 —— 这类记录继续被拒绝并计入视图的
「invalid coordinates」提示,上述归一只是对已合法值做的相机算术。也没有新增
配置键:`map` 块里已文档化的 `zoom` / `center` 仍是唯一的相机声明位,仍然完
全胜出,且只声明一半时另一半继续推导。空记录集不做 fit —— 开在整个世界,而
不是原点附近的一块海。
顺带:`index.registration.test.tsx` 把待测模块图的导入提到模块作用域
(AGENTS.md §9)。该文件单跑一直超时,只在同批别的测试文件先热了 transform
缓存时才绿;本包新增测试文件后这个顺序依赖就断了。
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Main entry (gzip)25.3 KB350 KB
Entry fileindex-D2LCbDTY.js
StatusPASS

📦 Bundle Size Report

PackageSizeGzipped
app-shell (index.js)9.56KB3.59KB
app-shell (runtime-config.js)7.42KB2.32KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)8.92KB3.41KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)1.17KB0.53KB
auth (AuthProvider.js)25.13KB5.40KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.13KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.64KB2.21KB
auth (SocialSignInButtons.js)9.60KB3.89KB
auth (UserMenu.js)3.40KB1.22KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.79KB
auth (createAuthenticatedFetch.js)6.34KB2.43KB
auth (index.js)2.71KB1.22KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.02KB0.88KB
auth (useIsWorkspaceAdmin.js)1.61KB0.85KB
collaboration (CommentThread.js)26.07KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.65KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)498.83KB111.26KB
core (index.js)4.11KB1.62KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)159.03KB44.08KB
fields (index.js)233.27KB58.22KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.35KB1.38KB
i18n (pickLocalized.js)3.69KB1.73KB
i18n (provider.js)23.12KB7.62KB
i18n (useDisplayLocale.js)2.84KB1.45KB
i18n (useObjectLabel.js)27.59KB6.63KB
i18n (useSafeTranslation.js)7.77KB3.13KB
layout (index.js)39.16KB10.97KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.74KB
mobile (index.js)1.50KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.71KB0.42KB
mobile (useResponsiveConfig.js)1.36KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)9.35KB3.31KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)4.42KB1.42KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.91KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.52KB
permissions (usePermissions.js)1.81KB0.83KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.62KB12.83KB
plugin-charts (index.js)64.75KB18.37KB
plugin-chatbot (index.js)181.21KB43.14KB
plugin-dashboard (index.js)127.85KB32.73KB
plugin-designer (index.js)212.39KB42.83KB
plugin-detail (index.js)240.05KB60.05KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)120.42KB29.03KB
plugin-gantt (index.js)164.10KB39.87KB
plugin-grid (index.js)197.61KB53.03KB
plugin-kanban (index.js)52.72KB14.54KB
plugin-list (index.js)111.22KB26.98KB
plugin-map (index.js)18.72KB6.09KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)41.97KB11.33KB
plugin-timeline (index.js)26.68KB7.66KB
plugin-tree (index.js)8.50KB2.88KB
plugin-view (index.js)83.81KB20.49KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.44KB0.22KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)3.77KB1.33KB
react (SchemaRenderer.js)27.53KB9.41KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.28KB0.68KB
react (schema-input.js)1.45KB0.83KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (index.js)4.77KB2.16KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)10.76KB3.17KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.29KB0.24KB
sdui-parser (validate.js)6.92KB2.40KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)0.20KB0.18KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)0.20KB0.18KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.87KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-retry.js)4.32KB2.02KB
types (index.js)3.05KB1.52KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)0.20KB0.18KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@yinlianghuiClaude

Copy link
Copy Markdown
CollaboratorAuthor

PM 验收 ✅ ACCEPT(#4941,批次 20)

实物核验(merge-base 279fb139d 与报告基线一致):7 files,+631/−26 —— 纯模块 camera.ts(短弧包围盒:相邻经度最宽空隙作切缝、跨反经线东边界写超 180 的 MapLibre 同形值、纬度钳 Mercator 带)+ initialViewState.bounds 交给 MapLibre 在真实容器尺寸下 fit;getMapConfig 默认分支不再合成伪相机(空集回落本就写在那里的 zoom 2)。模型标识 msg/diff 双 0;releases/ 零触碰;changeset patch。

CI 亲读:20/20 check runs completed,零失败(两项 path-filter skipped 计绿)。

前提拆半的处置采信:空视口半坐实并量到数值(修前 MapGL 首帧相机堪萨斯中部 zoom 10,最近记录 950km 外;修后 bounds 恰包全部种子坐标);相邻世界副本半在 showcase 数据上不成立(smartWrap 条件源码级归因不满足)但朴素 (min+max)/2 的对极点通路真实存在 —— 两半都修,报告者的读数也解释得通。契约优先做对了:平台 ListViewSchema 无相机声明位(实测 strictObject 七个可视化块无 map)、插件层 zoom/center 有文档化声明位 —— 已声明相机完全胜出,fitBounds 只在无声明时执行,零新配置键。

反向验证读数:①②精确命中(②的「纯推导测不到有没有人调用」正是组件级测试文件存在的理由);③预判 5 红实测 4 红,不符那条如实报告且机械原因成立(fixture 声明了 map 块走第二分支,变异作用面恰是「无配置」路径)。Monitor 未认证轮询的假 ALLDONE 自查纠正、终态结论来自认证读取 —— 读数纪律自觉,记为加分。PR footer 被平台写入路径剥离属环境侧行为,不记 dev 违规。

findings 处置:#5001(spec 侧 ListViewSchema 缺 map 配置块)按跨仓协议转 objectstack 队列;#5002(plugin-map README 全套虚构 API)定级入队;#5003(休眠双缺陷)入池。undraft + auto-merge(squash)。


Generated by Claude Code

@yinlianghui
yinlianghui marked this pull request as ready for review August 17, 2026 14:22
@yinlianghui
yinlianghui added this pull request to the merge queueAug 17, 2026
Merged via the queue into main with commit 25819c4Aug 17, 2026
20 checks passed
@yinlianghui
yinlianghui deleted the claude/issue-4941-map-fitbounds branch August 17, 2026 14:22
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

plugin-map: no fitBounds to data — markers land on an adjacent world copy, initial viewport shows an empty map

2 participants

@yinlianghui@claude