mirror of
https://github.com/evanwong89/footprint-map.git
synced 2026-07-22 08:33:04 +00:00
Replace dynamic AMap loader with static maps
This commit is contained in:
parent
b3d6f8b61f
commit
5ab1032908
28 changed files with 454 additions and 516 deletions
10
.github/workflows/release.yml
vendored
10
.github/workflows/release.yml
vendored
|
|
@ -7,6 +7,9 @@ on:
|
|||
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write
|
||||
attestations: write
|
||||
artifact-metadata: write
|
||||
|
||||
jobs:
|
||||
release:
|
||||
|
|
@ -21,6 +24,13 @@ jobs:
|
|||
- run: npm run check
|
||||
- run: npm run verify:release
|
||||
- run: npm audit --omit=dev --audit-level=high
|
||||
- name: Attest release assets
|
||||
uses: actions/attest@v4
|
||||
with:
|
||||
subject-path: |
|
||||
release/footprint-map/main.js
|
||||
release/footprint-map/manifest.json
|
||||
release/footprint-map/styles.css
|
||||
- name: Create GitHub release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ For local Obsidian testing, copy `release/footprint-map/main.js`, `manifest.json
|
|||
|
||||
- Keep footprint data and rendering core independent from Obsidian-specific APIs.
|
||||
- Do not add telemetry, advertising, or background network requests.
|
||||
- Never commit API keys, `securityJsCode`, tokens, real vaults, private photos, or location history.
|
||||
- Never commit API keys, tokens, real vaults, private photos, or location history.
|
||||
- Use synthetic or explicitly publishable fixtures.
|
||||
- Preserve provider attribution and document any new network service.
|
||||
- Add or update tests for behaviour changes.
|
||||
|
|
|
|||
19
README.md
19
README.md
|
|
@ -8,7 +8,7 @@ Footprint Map renders a local, time-ordered travel diary from photo locations in
|
|||
|
||||
- Generate visits from embedded JPEG, HEIC, and PNG photo metadata.
|
||||
- Group consecutive photos within 200 metres of the first photo into one place.
|
||||
- Render with OpenStreetMap, AMap JS API 2.0, custom raster tiles, or no basemap.
|
||||
- Render with OpenStreetMap, an AMap static basemap, custom raster tiles, or no basemap.
|
||||
- Keep photo markers, the timeline, and order lines available when a basemap fails.
|
||||
- Browse every photo associated with the selected place.
|
||||
- Export a local SVG fallback for readers that cannot run the plugin.
|
||||
|
|
@ -74,12 +74,13 @@ OpenStreetMap is the default and works without an API key. The plugin displays t
|
|||
|
||||
AMap is optional and intended mainly for users in mainland China.
|
||||
|
||||
1. Create an AMap **Web (JS API)** key.
|
||||
2. In **Settings → Footprint Map**, select or create an Obsidian secret containing the Web key.
|
||||
3. For keys created on or after 2021-12-02, select or create a second secret containing the matching `securityJsCode`.
|
||||
4. Select **AMap JS API 2.0** as the default basemap and reload Obsidian.
|
||||
1. Create an AMap **Web Service** API key. A Web (JS API) key is a different credential type and will not work here.
|
||||
2. In **Settings → Footprint Map**, select or create an Obsidian secret containing that Web Service key.
|
||||
3. Select **AMap static map** as the default basemap.
|
||||
|
||||
The plugin stores only SecretStorage identifiers in `data.json`. It never writes AMap credential values to Markdown, GeoJSON, SVG, or release files. Users are responsible for their own AMap account, credentials, quotas, domain restrictions, and provider terms.
|
||||
The plugin stores only a SecretStorage identifier in `data.json`. It never writes the AMap credential value to Markdown, GeoJSON, SVG, or release files. The old JavaScript API key and security code settings are removed during migration and are not silently reused as a Web Service key.
|
||||
|
||||
AMap is requested as a static image after the initial view settles and after a drag or zoom ends. The request is debounced; Footprint Map does not prefetch or bulk-cache AMap images. Photo cards, selection, the timeline, dashed order lines, and arrows are rendered locally by Leaflet and remain interactive. Users are responsible for their own AMap account, key restrictions, quotas, and provider terms.
|
||||
|
||||
### Custom tiles
|
||||
|
||||
|
|
@ -100,7 +101,7 @@ When you explicitly run a generation or export command, the plugin may create or
|
|||
Online basemaps necessarily contact third parties:
|
||||
|
||||
- OpenStreetMap receives normal tile requests, including the requesting IP address and tile coordinates for the visible map area.
|
||||
- AMap receives script and map requests, the user-provided Web key, and information needed to display the visible map area.
|
||||
- AMap receives static-map requests containing the user-provided Web Service key, visible map centre, zoom level, requested image size, and the requesting IP address.
|
||||
- A custom tile provider receives requests for the tiles visible in the current viewport.
|
||||
|
||||
Set `tiles: false` to use no-basemap mode and avoid these basemap requests. Refer to each provider's privacy policy and terms for its own processing practices.
|
||||
|
|
@ -113,7 +114,7 @@ GeoJSON, SVG, Markdown, and photo references remain ordinary files in your vault
|
|||
|
||||
- **No visits generated:** verify that the embedded photos contain GPS and an explicit timezone offset.
|
||||
- **Basemap missing:** markers and the timeline should remain available. Check connectivity and the selected provider's configuration.
|
||||
- **AMap fails:** confirm that the key type is Web (JS API), the matching `securityJsCode` is selected, and any domain restrictions allow the Obsidian environment.
|
||||
- **AMap fails:** confirm that the selected secret contains a Web Service API key, the key is enabled for the static map service, and the account still has available quota. The plugin keeps markers, lines, photos, and the timeline available without a basemap.
|
||||
- **HEIC preview unavailable:** the visit remains valid even if the host cannot decode the image. Converting the displayed copy to PNG or JPEG may help.
|
||||
- **Wrong language:** choose Auto, English, or Simplified Chinese under **Settings → Footprint Map**, then reload the plugin.
|
||||
|
||||
|
|
@ -158,7 +159,7 @@ Footprint Map 从 Obsidian 笔记引用的本地照片中读取 GPS 与带时区
|
|||
4. 插件会在笔记旁生成 GeoJSON,并在笔记中加入 `footprint-map` 代码块。
|
||||
5. 如需静态降级图,再执行“导出当前笔记的静态足迹图”。
|
||||
|
||||
默认底图为 OpenStreetMap,无需 Key。中国大陆用户可以切换高德,并在 Obsidian 安全存储中分别选择或创建 Web Key 与配套 `securityJsCode`。高级用户也可以使用自定义瓦片,或设置 `tiles: false` 完全关闭在线底图。
|
||||
默认底图为 OpenStreetMap,无需 Key。中国大陆用户可以切换高德静态地图,并在 Obsidian 安全存储中选择或创建包含“Web 服务 API Key”的凭据;旧的 Web 端(JS API)Key 不能代替该 Key。高德请求只在视野稳定或拖动、缩放结束后防抖发起;点位、连线、照片和时间轴仍在本地渲染。高级用户也可以使用自定义瓦片,或设置 `tiles: false` 完全关闭在线底图。
|
||||
|
||||
插件不包含遥测、广告或账户系统。照片与 EXIF 在本地读取;启用在线底图时,当前视野对应的瓦片请求会发送给所选地图服务商。禁用或卸载插件不会自动删除已生成的 GeoJSON、SVG 或 Markdown 内容。
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,6 @@ Please do not disclose suspected vulnerabilities in a public issue.
|
|||
|
||||
Use GitHub's private vulnerability reporting for this repository when it is available. If private reporting is unavailable, contact the author through the public profile linked from `manifest.json` and provide only enough information to arrange a private report.
|
||||
|
||||
Useful reports include the affected plugin version, Obsidian version and platform, reproduction steps, impact, and any suggested mitigation. Do not include real vault contents, photos, coordinates, API keys, `securityJsCode`, or other credentials.
|
||||
Useful reports include the affected plugin version, Obsidian version and platform, reproduction steps, impact, and any suggested mitigation. Do not include real vault contents, photos, coordinates, API keys, or other credentials.
|
||||
|
||||
Footprint Map does not require a project-operated server. Reports involving OpenStreetMap, AMap, or a custom tile provider may also need to be reported to that provider when the issue is in its service rather than this plugin.
|
||||
|
|
|
|||
4
index.md
4
index.md
|
|
@ -6,7 +6,7 @@
|
|||
|
||||
- 状态:MVP 已实现并通过类型检查、自动化测试与生产构建;待 Obsidian Desktop/Mobile 和真实照片样本验收。
|
||||
- 关联需求:[[../../Requirements/REQ-004-可移植Markdown互动足迹地图|REQ-004:可移植 Markdown 互动足迹地图]]
|
||||
- 当前范围:Obsidian 日记内嵌互动地图、Leaflet/OpenStreetMap 与高德 JS API 2.0 双渲染器、照片缩略图点位与地图外横向照片浏览、200 米连续照片聚合、Vault 内 JPEG/HEIC/PNG 的 TypeScript EXIF 解析、GeoJSON 通用数据、独立 HTML 查看器与静态降级图。
|
||||
- 当前范围:Obsidian 日记内嵌互动地图、Leaflet/OpenStreetMap 与高德静态地图底图、照片缩略图点位与地图外横向照片浏览、200 米连续照片聚合、Vault 内 JPEG/HEIC/PNG 的 TypeScript EXIF 解析、GeoJSON 通用数据、独立 HTML 查看器与静态降级图。
|
||||
- 项目边界:独立编写与构建,不依赖工作区内其他本地项目。
|
||||
- 非目标:操作系统后台任务、Apple 原生框架、原生配套 App 及 Python/Swift 外部运行时。
|
||||
- 实施基线:单日地图、Leaflet、外部 GeoJSON、可关闭的 OpenStreetMap 在线瓦片、缺失时区不猜测、HEIC 无法显示时占位。
|
||||
|
|
@ -30,4 +30,4 @@
|
|||
|
||||
## 下一步
|
||||
|
||||
在真实 Obsidian Vault 中继续验收高德 Web Key、Desktop/Mobile、照片资源 URL、EXIF 时区和 HEIC 预览后,再准备公开发布;`securityJsCode` 仅在控制台实际提供时配置。
|
||||
在真实 Obsidian Vault 中继续验收高德 Web 服务 Key、静态底图刷新、Desktop/Mobile、照片资源 URL、EXIF 时区和 HEIC 预览;新版本通过 Obsidian Community 自动审查后再发布目录条目。
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"id": "footprint-map",
|
||||
"name": "Footprint Map",
|
||||
"version": "0.4.0",
|
||||
"version": "0.4.1",
|
||||
"minAppVersion": "1.11.5",
|
||||
"description": "Render local photo footprints as interactive time-ordered maps.",
|
||||
"author": "Evan",
|
||||
|
|
|
|||
6
package-lock.json
generated
6
package-lock.json
generated
|
|
@ -1,13 +1,13 @@
|
|||
{
|
||||
"name": "footprint-map",
|
||||
"version": "0.4.0",
|
||||
"version": "0.4.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "footprint-map",
|
||||
"version": "0.4.0",
|
||||
"license": "UNLICENSED",
|
||||
"version": "0.4.1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"exifr": "7.1.3",
|
||||
"leaflet": "1.9.4",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "footprint-map",
|
||||
"version": "0.4.0",
|
||||
"version": "0.4.1",
|
||||
"private": true,
|
||||
"description": "Portable Markdown footprint maps for Obsidian and browsers",
|
||||
"license": "MIT",
|
||||
|
|
|
|||
|
|
@ -68,7 +68,7 @@ export const verifyRelease = async (root = process.cwd()) => {
|
|||
}
|
||||
|
||||
const config = await readFile(resolve(root, "standalone/amap-config.js"), "utf8");
|
||||
for (const field of ["key", "securityJsCode"]) {
|
||||
for (const field of ["webServiceKey"]) {
|
||||
const match = config.match(new RegExp(`${field}\\s*:\\s*["']([^"']*)["']`));
|
||||
if (!match) errors.push(`Unable to verify standalone AMap ${field}.`);
|
||||
else if (match[1].trim()) errors.push(`standalone/amap-config.js contains a non-empty ${field}.`);
|
||||
|
|
@ -82,7 +82,7 @@ export const verifyRelease = async (root = process.cwd()) => {
|
|||
|
||||
const bundle = await readFile(resolve(root, "release/footprint-map/main.js"), "utf8");
|
||||
if (/sourceMappingURL=/.test(bundle)) errors.push("Production main.js contains a source map reference.");
|
||||
if (/\b(?:amapKey|securityJsCode)\s*[:=]\s*["'][A-Za-z0-9_-]{16,}["']/.test(bundle)) {
|
||||
if (/\b(?:amapKey|webServiceKey|securityJsCode)\s*[:=]\s*["'][A-Za-z0-9_-]{16,}["']/.test(bundle)) {
|
||||
errors.push("Production main.js may contain a hard-coded AMap credential.");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,15 +7,14 @@ import {
|
|||
} from "../../platform/tile-provider";
|
||||
import { element } from "../../renderer/dom";
|
||||
import { FootprintMapController } from "../../renderer/map-controller";
|
||||
import { AMapController } from "../../renderer/amap-map-controller";
|
||||
import type { MapController } from "../../renderer/controller";
|
||||
import { wgs84ToGcj02 } from "../../renderer/amap-coordinate-conversion";
|
||||
import type { ConfigWarning, FootprintMapBlockConfig } from "./code-block-config";
|
||||
import { ObsidianFileStore } from "./obsidian-file-store";
|
||||
import { ObsidianResourceResolver } from "./obsidian-resources";
|
||||
import { resolveVaultPath } from "./vault-path";
|
||||
import type { FootprintMapSettings } from "./settings";
|
||||
import type { I18n } from "../../i18n";
|
||||
import type { AMapCredentials } from "../../renderer/amap-loader";
|
||||
|
||||
export class FootprintRenderChild extends MarkdownRenderChild {
|
||||
private controller?: MapController;
|
||||
|
|
@ -26,7 +25,7 @@ export class FootprintRenderChild extends MarkdownRenderChild {
|
|||
private readonly config: FootprintMapBlockConfig,
|
||||
private readonly markdownSourcePath: string,
|
||||
private readonly settings: FootprintMapSettings,
|
||||
private readonly amapCredentials: AMapCredentials,
|
||||
private readonly amapWebServiceKey: string,
|
||||
private readonly i18n: I18n,
|
||||
) {
|
||||
super(containerEl);
|
||||
|
|
@ -49,36 +48,32 @@ export class FootprintRenderChild extends MarkdownRenderChild {
|
|||
const resourceResolver = new ObsidianResourceResolver(this.vault);
|
||||
const provider = this.config.tileProvider ?? this.settings.defaultTileProvider;
|
||||
const warnings: Array<string | ConfigWarning> = [...this.config.warnings];
|
||||
const renderWithoutAMap = (tileProvider: TileProviderConfig | null): void => {
|
||||
const renderLeaflet = (
|
||||
tileProvider: TileProviderConfig | null,
|
||||
amapStaticMap?: { key: string },
|
||||
): void => {
|
||||
this.controller = new FootprintMapController({
|
||||
container: this.containerEl,
|
||||
model: renderModel,
|
||||
resourceResolver,
|
||||
resourceBasePath: geoJsonPath,
|
||||
tileProvider,
|
||||
...(amapStaticMap ? {
|
||||
coordinateTransform: wgs84ToGcj02,
|
||||
amapStaticMap,
|
||||
} : {}),
|
||||
height: this.config.height,
|
||||
i18n: this.i18n,
|
||||
});
|
||||
};
|
||||
if (!this.config.tiles) {
|
||||
renderWithoutAMap(null);
|
||||
renderLeaflet(null);
|
||||
} else if (provider === "amap") {
|
||||
try {
|
||||
this.controller = await AMapController.create({
|
||||
container: this.containerEl,
|
||||
model: renderModel,
|
||||
resourceResolver,
|
||||
resourceBasePath: geoJsonPath,
|
||||
credentials: this.amapCredentials,
|
||||
height: this.config.height,
|
||||
i18n: this.i18n,
|
||||
});
|
||||
} catch (error) {
|
||||
warnings.push(`${this.i18n.t("amapUnavailable")} ${this.i18n.error(error)}`);
|
||||
if (!this.amapCredentials.securityJsCode) {
|
||||
warnings.push(this.i18n.t("amapSecurityCodeMissing"));
|
||||
}
|
||||
renderWithoutAMap(null);
|
||||
if (this.amapWebServiceKey) {
|
||||
renderLeaflet(null, { key: this.amapWebServiceKey });
|
||||
} else {
|
||||
warnings.push(this.i18n.error(new Error("FM_AMAP_KEY_REQUIRED: AMap Web Service key is missing.")));
|
||||
renderLeaflet(null);
|
||||
}
|
||||
} else if (provider === "custom") {
|
||||
const resolution = resolveCustomTileProvider({
|
||||
|
|
@ -87,9 +82,9 @@ export class FootprintRenderChild extends MarkdownRenderChild {
|
|||
maxZoom: this.settings.customTileMaxZoom,
|
||||
});
|
||||
if (resolution.warning) warnings.push(this.i18n.t(resolution.warning));
|
||||
renderWithoutAMap(resolution.provider);
|
||||
renderLeaflet(resolution.provider);
|
||||
} else {
|
||||
renderWithoutAMap(OPEN_STREET_MAP_TILES);
|
||||
renderLeaflet(OPEN_STREET_MAP_TILES);
|
||||
}
|
||||
if (warnings.length) {
|
||||
const warningRegion = element("aside", "footprint-map-issues");
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import { serializeFootprintGeoJson } from "../../core/serialize-footprint";
|
|||
import { validateFootprintGeoJson } from "../../core/validate-footprint";
|
||||
import { ExifrPhotoMetadataReader } from "../../metadata/exifr-reader";
|
||||
import { renderStaticSvg } from "../../renderer/static-svg-renderer";
|
||||
import type { AMapCredentials } from "../../renderer/amap-loader";
|
||||
import { createI18n, type I18n } from "../../i18n";
|
||||
import { parseCodeBlockConfig } from "./code-block-config";
|
||||
import { FootprintRenderChild } from "./footprint-render-child";
|
||||
|
|
@ -15,12 +14,12 @@ import { basename, dirname, resolveVaultPath } from "./vault-path";
|
|||
import { DEFAULT_SETTINGS, FootprintMapSettingTab, type FootprintMapSettings } from "./settings";
|
||||
|
||||
const isSupportedPhoto = (file: TFile): boolean => ["jpg", "jpeg", "heic", "png"].includes(file.extension.toLowerCase());
|
||||
const AMAP_KEY_SECRET_ID = "footprint-map-amap-web-key";
|
||||
const AMAP_SECURITY_CODE_SECRET_ID = "footprint-map-amap-security-code";
|
||||
|
||||
type LegacyFootprintMapSettings = Partial<FootprintMapSettings> & {
|
||||
amapKey?: unknown;
|
||||
amapSecurityJsCode?: unknown;
|
||||
amapKeySecretId?: unknown;
|
||||
amapSecurityJsCodeSecretId?: unknown;
|
||||
};
|
||||
|
||||
export class FootprintMapPlugin extends Plugin {
|
||||
|
|
@ -39,7 +38,7 @@ export class FootprintMapPlugin extends Plugin {
|
|||
config,
|
||||
context.sourcePath,
|
||||
this.settings,
|
||||
this.getAMapCredentials(),
|
||||
this.getAMapWebServiceKey(),
|
||||
this.getI18n(),
|
||||
));
|
||||
} catch (error) {
|
||||
|
|
@ -74,14 +73,10 @@ export class FootprintMapPlugin extends Plugin {
|
|||
await this.saveData(this.settings);
|
||||
}
|
||||
|
||||
getAMapCredentials(): AMapCredentials {
|
||||
const key = this.settings.amapKeySecretId
|
||||
? this.app.secretStorage.getSecret(this.settings.amapKeySecretId)?.trim() ?? ""
|
||||
getAMapWebServiceKey(): string {
|
||||
return this.settings.amapWebServiceKeySecretId
|
||||
? this.app.secretStorage.getSecret(this.settings.amapWebServiceKeySecretId)?.trim() ?? ""
|
||||
: "";
|
||||
const securityJsCode = this.settings.amapSecurityJsCodeSecretId
|
||||
? this.app.secretStorage.getSecret(this.settings.amapSecurityJsCodeSecretId)?.trim() ?? ""
|
||||
: "";
|
||||
return securityJsCode ? { key, securityJsCode } : { key };
|
||||
}
|
||||
|
||||
getI18n(): I18n {
|
||||
|
|
@ -90,20 +85,20 @@ export class FootprintMapPlugin extends Plugin {
|
|||
|
||||
private async loadSettings(): Promise<void> {
|
||||
const stored = (await this.loadData() ?? {}) as LegacyFootprintMapSettings;
|
||||
const legacyKey = typeof stored.amapKey === "string" ? stored.amapKey.trim() : "";
|
||||
const legacySecurityCode = typeof stored.amapSecurityJsCode === "string" ? stored.amapSecurityJsCode.trim() : "";
|
||||
const migrated = Object.hasOwn(stored, "amapKey") || Object.hasOwn(stored, "amapSecurityJsCode");
|
||||
const { amapKey: _legacyKey, amapSecurityJsCode: _legacySecurityCode, ...current } = stored;
|
||||
const migrated = [
|
||||
"amapKey",
|
||||
"amapSecurityJsCode",
|
||||
"amapKeySecretId",
|
||||
"amapSecurityJsCodeSecretId",
|
||||
].some((field) => Object.hasOwn(stored, field));
|
||||
const {
|
||||
amapKey: _legacyKey,
|
||||
amapSecurityJsCode: _legacySecurityCode,
|
||||
amapKeySecretId: _legacyKeySecretId,
|
||||
amapSecurityJsCodeSecretId: _legacySecurityCodeSecretId,
|
||||
...current
|
||||
} = stored;
|
||||
this.settings = { ...DEFAULT_SETTINGS, ...current };
|
||||
|
||||
if (legacyKey && !this.settings.amapKeySecretId) {
|
||||
this.app.secretStorage.setSecret(AMAP_KEY_SECRET_ID, legacyKey);
|
||||
this.settings.amapKeySecretId = AMAP_KEY_SECRET_ID;
|
||||
}
|
||||
if (legacySecurityCode && !this.settings.amapSecurityJsCodeSecretId) {
|
||||
this.app.secretStorage.setSecret(AMAP_SECURITY_CODE_SECRET_ID, legacySecurityCode);
|
||||
this.settings.amapSecurityJsCodeSecretId = AMAP_SECURITY_CODE_SECRET_ID;
|
||||
}
|
||||
if (migrated) await this.saveSettings();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,12 @@
|
|||
import { App, Notice, PluginSettingTab, SecretComponent, Setting } from "obsidian";
|
||||
import {
|
||||
App,
|
||||
Notice,
|
||||
PluginSettingTab,
|
||||
SecretComponent,
|
||||
Setting,
|
||||
type SettingDefinitionItem,
|
||||
type SettingDefinitionRender,
|
||||
} from "obsidian";
|
||||
import type { LanguagePreference } from "../../i18n";
|
||||
import type { FootprintMapPlugin } from "./plugin";
|
||||
|
||||
|
|
@ -7,8 +15,7 @@ export type TileProviderName = "osm" | "amap" | "custom";
|
|||
export interface FootprintMapSettings {
|
||||
language: LanguagePreference;
|
||||
defaultTileProvider: TileProviderName;
|
||||
amapKeySecretId: string;
|
||||
amapSecurityJsCodeSecretId: string;
|
||||
amapWebServiceKeySecretId: string;
|
||||
customTileUrl: string;
|
||||
customTileAttribution: string;
|
||||
customTileMaxZoom: number;
|
||||
|
|
@ -17,8 +24,7 @@ export interface FootprintMapSettings {
|
|||
export const DEFAULT_SETTINGS: FootprintMapSettings = {
|
||||
language: "auto",
|
||||
defaultTileProvider: "osm",
|
||||
amapKeySecretId: "",
|
||||
amapSecurityJsCodeSecretId: "",
|
||||
amapWebServiceKeySecretId: "",
|
||||
customTileUrl: "",
|
||||
customTileAttribution: "",
|
||||
customTileMaxZoom: 19,
|
||||
|
|
@ -29,87 +35,93 @@ export class FootprintMapSettingTab extends PluginSettingTab {
|
|||
super(app, plugin);
|
||||
}
|
||||
|
||||
display(): void {
|
||||
const { containerEl } = this;
|
||||
containerEl.empty();
|
||||
getSettingDefinitions(): SettingDefinitionItem[] {
|
||||
const i18n = this.plugin.getI18n();
|
||||
new Setting(containerEl)
|
||||
.setName(i18n.t("language"))
|
||||
.setDesc(i18n.t("languageDesc"))
|
||||
.addDropdown((dropdown) => dropdown
|
||||
.addOption("auto", i18n.t("languageAuto"))
|
||||
.addOption("en", i18n.t("languageEnglish"))
|
||||
.addOption("zh-CN", i18n.t("languageChinese"))
|
||||
.setValue(this.plugin.settings.language)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.language = value as LanguagePreference;
|
||||
await this.plugin.saveSettings();
|
||||
const nextI18n = this.plugin.getI18n();
|
||||
new Notice(`Footprint Map: ${nextI18n.t("languageChanged")}`, 8000);
|
||||
this.display();
|
||||
}));
|
||||
new Setting(containerEl)
|
||||
.setName(i18n.t("defaultBasemap"))
|
||||
.setDesc(i18n.t("defaultBasemapDesc"))
|
||||
.addDropdown((dropdown) => dropdown
|
||||
.addOption("osm", i18n.t("osmProvider"))
|
||||
.addOption("amap", i18n.t("amapProvider"))
|
||||
.addOption("custom", i18n.t("customProvider"))
|
||||
.setValue(this.plugin.settings.defaultTileProvider)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.defaultTileProvider = value as TileProviderName;
|
||||
await this.plugin.saveSettings();
|
||||
}));
|
||||
new Setting(containerEl)
|
||||
.setName(i18n.t("amapKey"))
|
||||
.setDesc(i18n.t("amapKeyDesc"))
|
||||
.addComponent((container) => new SecretComponent(this.app, container)
|
||||
.setValue(this.plugin.settings.amapKeySecretId)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.amapKeySecretId = value.trim();
|
||||
await this.plugin.saveSettings();
|
||||
}));
|
||||
new Setting(containerEl)
|
||||
.setName(i18n.t("amapSecurityCode"))
|
||||
.setDesc(i18n.t("amapSecurityCodeDesc"))
|
||||
.addComponent((container) => new SecretComponent(this.app, container)
|
||||
.setValue(this.plugin.settings.amapSecurityJsCodeSecretId)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.amapSecurityJsCodeSecretId = value.trim();
|
||||
await this.plugin.saveSettings();
|
||||
}));
|
||||
new Setting(containerEl)
|
||||
.setName(i18n.t("customTileUrl"))
|
||||
.setDesc(i18n.t("customTileUrlDesc"))
|
||||
.addText((text) => {
|
||||
text.setPlaceholder("https://example.com/{z}/{x}/{y}.png")
|
||||
.setValue(this.plugin.settings.customTileUrl)
|
||||
return [
|
||||
{
|
||||
name: i18n.t("language"),
|
||||
desc: i18n.t("languageDesc"),
|
||||
render: (setting) => { setting.addDropdown((dropdown) => dropdown
|
||||
.addOption("auto", i18n.t("languageAuto"))
|
||||
.addOption("en", i18n.t("languageEnglish"))
|
||||
.addOption("zh-CN", i18n.t("languageChinese"))
|
||||
.setValue(this.plugin.settings.language)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.customTileUrl = value.trim();
|
||||
this.plugin.settings.language = value as LanguagePreference;
|
||||
await this.plugin.saveSettings();
|
||||
});
|
||||
text.inputEl.autocomplete = "off";
|
||||
});
|
||||
new Setting(containerEl)
|
||||
.setName(i18n.t("customTileAttribution"))
|
||||
.setDesc(i18n.t("customTileAttributionDesc"))
|
||||
.addText((text) => text
|
||||
.setPlaceholder(i18n.t("customTileAttributionPlaceholder"))
|
||||
.setValue(this.plugin.settings.customTileAttribution)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.customTileAttribution = value.trim();
|
||||
await this.plugin.saveSettings();
|
||||
}));
|
||||
new Setting(containerEl)
|
||||
.setName(i18n.t("customTileMaxZoom"))
|
||||
.setDesc(i18n.t("customTileMaxZoomDesc"))
|
||||
.addSlider((slider) => slider
|
||||
.setLimits(1, 24, 1)
|
||||
.setDynamicTooltip()
|
||||
.setValue(this.plugin.settings.customTileMaxZoom)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.customTileMaxZoom = value;
|
||||
await this.plugin.saveSettings();
|
||||
}));
|
||||
const nextI18n = this.plugin.getI18n();
|
||||
new Notice(`Footprint Map: ${nextI18n.t("languageChanged")}`, 8000);
|
||||
this.update();
|
||||
})); },
|
||||
},
|
||||
{
|
||||
name: i18n.t("defaultBasemap"),
|
||||
desc: i18n.t("defaultBasemapDesc"),
|
||||
render: (setting) => { setting.addDropdown((dropdown) => dropdown
|
||||
.addOption("osm", i18n.t("osmProvider"))
|
||||
.addOption("amap", i18n.t("amapProvider"))
|
||||
.addOption("custom", i18n.t("customProvider"))
|
||||
.setValue(this.plugin.settings.defaultTileProvider)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.defaultTileProvider = value as TileProviderName;
|
||||
await this.plugin.saveSettings();
|
||||
})); },
|
||||
},
|
||||
{
|
||||
name: i18n.t("amapKey"),
|
||||
desc: i18n.t("amapKeyDesc"),
|
||||
render: (setting) => { setting.addComponent((container) => new SecretComponent(this.app, container)
|
||||
.setValue(this.plugin.settings.amapWebServiceKeySecretId)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.amapWebServiceKeySecretId = value.trim();
|
||||
await this.plugin.saveSettings();
|
||||
})); },
|
||||
},
|
||||
{
|
||||
name: i18n.t("customTileUrl"),
|
||||
desc: i18n.t("customTileUrlDesc"),
|
||||
render: (setting) => { setting.addText((input) => {
|
||||
input.setPlaceholder("https://example.com/{z}/{x}/{y}.png")
|
||||
.setValue(this.plugin.settings.customTileUrl)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.customTileUrl = value.trim();
|
||||
await this.plugin.saveSettings();
|
||||
});
|
||||
input.inputEl.autocomplete = "off";
|
||||
}); },
|
||||
},
|
||||
{
|
||||
name: i18n.t("customTileAttribution"),
|
||||
desc: i18n.t("customTileAttributionDesc"),
|
||||
render: (setting) => { setting.addText((input) => input
|
||||
.setPlaceholder(i18n.t("customTileAttributionPlaceholder"))
|
||||
.setValue(this.plugin.settings.customTileAttribution)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.customTileAttribution = value.trim();
|
||||
await this.plugin.saveSettings();
|
||||
})); },
|
||||
},
|
||||
{
|
||||
name: i18n.t("customTileMaxZoom"),
|
||||
desc: i18n.t("customTileMaxZoomDesc"),
|
||||
render: (setting) => { setting.addSlider((slider) => slider
|
||||
.setLimits(1, 24, 1)
|
||||
.setValue(this.plugin.settings.customTileMaxZoom)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.customTileMaxZoom = value;
|
||||
await this.plugin.saveSettings();
|
||||
})); },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** Compatibility fallback for Obsidian versions before the declarative settings API. */
|
||||
display(): void {
|
||||
this.containerEl.empty();
|
||||
for (const definition of this.getSettingDefinitions() as SettingDefinitionRender[]) {
|
||||
const setting = new Setting(this.containerEl).setName(definition.name);
|
||||
if (definition.desc) setting.setDesc(definition.desc);
|
||||
definition.render(setting, undefined as never);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,13 +3,35 @@ import { OPEN_STREET_MAP_TILES } from "../../platform/tile-provider";
|
|||
import { FootprintMapController } from "../../renderer/map-controller";
|
||||
import { element } from "../../renderer/dom";
|
||||
import { BrowserResourceResolver } from "./browser-resources";
|
||||
import { AMapController } from "../../renderer/amap-map-controller";
|
||||
import type { MapController } from "../../renderer/controller";
|
||||
import { createI18n } from "../../i18n";
|
||||
import { wgs84ToGcj02 } from "../../renderer/amap-coordinate-conversion";
|
||||
|
||||
if (typeof globalThis.createEl !== "function") {
|
||||
Object.defineProperty(globalThis, "createEl", {
|
||||
configurable: true,
|
||||
value: <K extends keyof HTMLElementTagNameMap>(tag: K): HTMLElementTagNameMap[K] => (
|
||||
document.createElementNS("http://www.w3.org/1999/xhtml", tag) as HTMLElementTagNameMap[K]
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
let controller: MapController | undefined;
|
||||
const i18n = createI18n("en", "en");
|
||||
|
||||
const readSource = (source: string): Promise<string> => new Promise((resolve, reject) => {
|
||||
const request = new XMLHttpRequest();
|
||||
request.open("GET", source);
|
||||
request.addEventListener("load", () => {
|
||||
if (request.status >= 200 && request.status < 300) resolve(request.responseText);
|
||||
else reject(new Error(`FM_SOURCE_NOT_FOUND: ${request.status} ${request.statusText}`));
|
||||
}, { once: true });
|
||||
request.addEventListener("error", () => {
|
||||
reject(new Error("FM_SOURCE_NOT_FOUND: The standalone source request failed."));
|
||||
}, { once: true });
|
||||
request.send();
|
||||
});
|
||||
|
||||
const renderError = (container: HTMLElement, error: unknown): void => {
|
||||
container.replaceChildren();
|
||||
const panel = element("section", "footprint-map-error");
|
||||
|
|
@ -28,23 +50,21 @@ const loadSource = async (
|
|||
const container = document.querySelector<HTMLElement>("#footprint-map");
|
||||
if (!container) return;
|
||||
try {
|
||||
const response = await fetch(source);
|
||||
if (!response.ok) throw new Error(`FM_SOURCE_NOT_FOUND: ${response.status} ${response.statusText}`);
|
||||
const model = loadFootprint(await response.text());
|
||||
const model = loadFootprint(await readSource(source));
|
||||
controller?.destroy();
|
||||
const resourceResolver = new BrowserResourceResolver();
|
||||
if (tilesEnabled && provider === "amap") {
|
||||
const config = window.FOOTPRINT_MAP_AMAP_CONFIG;
|
||||
if (!config?.key.trim()) throw new Error("FM_STANDALONE_AMAP_KEY_REQUIRED: AMap Web key is missing.");
|
||||
const credentials = config.securityJsCode?.trim()
|
||||
? { key: config.key, securityJsCode: config.securityJsCode }
|
||||
: { key: config.key };
|
||||
controller = await AMapController.create({
|
||||
const key = config?.webServiceKey.trim() ?? "";
|
||||
if (!key) throw new Error("FM_STANDALONE_AMAP_KEY_REQUIRED: AMap Web Service key is missing.");
|
||||
controller = new FootprintMapController({
|
||||
container,
|
||||
model,
|
||||
resourceResolver,
|
||||
resourceBasePath: source,
|
||||
credentials,
|
||||
tileProvider: null,
|
||||
coordinateTransform: wgs84ToGcj02,
|
||||
amapStaticMap: { key },
|
||||
height: 520,
|
||||
i18n,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -68,7 +68,8 @@ const parseVisit = (
|
|||
issues: [warning("FM_VISIT_INVALID_GEOMETRY", "到访点 geometry 必须是 Point,已忽略。", { featureIndex })],
|
||||
};
|
||||
}
|
||||
const [longitude, latitude] = geometry.coordinates;
|
||||
const longitude: unknown = geometry.coordinates[0];
|
||||
const latitude: unknown = geometry.coordinates[1];
|
||||
if (
|
||||
typeof longitude !== "number" ||
|
||||
typeof latitude !== "number" ||
|
||||
|
|
|
|||
35
src/i18n.ts
35
src/i18n.ts
|
|
@ -13,14 +13,10 @@ const EN = {
|
|||
defaultBasemap: "Default basemap",
|
||||
defaultBasemapDesc: "Used when a code block does not specify tileProvider. tiles: false still disables the basemap.",
|
||||
osmProvider: "OpenStreetMap / Leaflet",
|
||||
amapProvider: "AMap JS API 2.0",
|
||||
amapProvider: "AMap static map",
|
||||
customProvider: "Custom tiles / Leaflet",
|
||||
amapKey: "AMap Web key",
|
||||
amapKeyDesc: "Select or create an Obsidian secret. Only its secret ID is saved in plugin data; the value is never written to Markdown, GeoJSON, or the plugin bundle.",
|
||||
amapKeyPlaceholder: "Web (JavaScript API) key",
|
||||
amapSecurityCode: "AMap security code",
|
||||
amapSecurityCodeDesc: "Select or create the matching Obsidian secret. It is required for Web (JS API) keys created on or after December 2, 2021. Reload Obsidian after changing it.",
|
||||
amapSecurityCodePlaceholder: "Matching securityJsCode",
|
||||
amapKey: "AMap Web Service key",
|
||||
amapKeyDesc: "Select or create an Obsidian secret containing an AMap Web Service API key. Static map requests send the key, visible center, zoom, image size, and network IP to AMap; photos, Markdown, and GeoJSON files are not uploaded.",
|
||||
customTileUrl: "Custom tile URL",
|
||||
customTileUrlDesc: "Advanced. Must use HTTP(S) and include {z}, {x}, and {y}. Most users can leave this empty.",
|
||||
customTileAttribution: "Custom tile attribution",
|
||||
|
|
@ -41,8 +37,7 @@ const EN = {
|
|||
visitNumber: "Place {sequence}",
|
||||
timelineAria: "Visits in time order",
|
||||
basemapUnavailable: "The basemap failed to load and was removed. Photo markers, the timeline, and connecting lines remain available.",
|
||||
amapUnavailable: "AMap failed to load. The map is using no-basemap mode, and footprint data remains available.",
|
||||
amapSecurityCodeMissing: "No securityJsCode is configured. AMap requires it for Web (JS API) keys created on or after December 2, 2021.",
|
||||
amapUnavailable: "The AMap static basemap failed to load. The map is using no-basemap mode, and footprint data remains available.",
|
||||
mapLoadFailed: "Footprint map failed to load",
|
||||
mapName: "Footprint map",
|
||||
staticPreviewAlt: "{title} static preview",
|
||||
|
|
@ -82,14 +77,10 @@ const ZH: Record<MessageKey, string> = {
|
|||
defaultBasemap: "默认底图",
|
||||
defaultBasemapDesc: "代码块未填写 tileProvider 时使用。已有 tiles: false 仍会完全关闭底图。",
|
||||
osmProvider: "OpenStreetMap / Leaflet",
|
||||
amapProvider: "高德地图 JS API 2.0",
|
||||
amapProvider: "高德静态地图",
|
||||
customProvider: "自定义瓦片 / Leaflet",
|
||||
amapKey: "高德 Web Key",
|
||||
amapKeyDesc: "在 Obsidian 安全存储中选择或创建凭据。插件 data.json 只保存凭据 ID,不把凭据值写入 Markdown、GeoJSON 或构建产物。",
|
||||
amapKeyPlaceholder: "Web 端(JS API)Key",
|
||||
amapSecurityCode: "高德安全密钥",
|
||||
amapSecurityCodeDesc: "在 Obsidian 安全存储中选择或创建配套凭据。2021-12-02 及以后创建的 Web 端(JS API)Key 必须使用 securityJsCode;修改后需重新加载 Obsidian。",
|
||||
amapSecurityCodePlaceholder: "与 Web Key 配套的 securityJsCode",
|
||||
amapKey: "高德 Web 服务 Key",
|
||||
amapKeyDesc: "在 Obsidian 安全存储中选择或创建包含高德 Web 服务 API Key 的凭据。静态地图请求会向高德发送 Key、可见中心点、缩放级别、图片尺寸和网络 IP;不会上传照片、Markdown 或 GeoJSON 文件。",
|
||||
customTileUrl: "自定义瓦片 URL",
|
||||
customTileUrlDesc: "高级设置。必须使用 HTTP(S),并包含 {z}、{x}、{y};普通用户无需填写。",
|
||||
customTileAttribution: "自定义瓦片署名",
|
||||
|
|
@ -110,8 +101,7 @@ const ZH: Record<MessageKey, string> = {
|
|||
visitNumber: "到访点 {sequence}",
|
||||
timelineAria: "按时间排序的到访列表",
|
||||
basemapUnavailable: "底图加载失败,已切换为无底图模式;照片点、时间线和连线仍可使用。",
|
||||
amapUnavailable: "高德底图加载失败,已使用无底图模式;足迹数据仍可用。",
|
||||
amapSecurityCodeMissing: "当前未配置 securityJsCode。2021-12-02 及以后创建的高德 Web 端(JS API)Key 必须搭配安全密钥。",
|
||||
amapUnavailable: "高德静态底图加载失败,已使用无底图模式;足迹数据仍可用。",
|
||||
mapLoadFailed: "足迹地图无法加载",
|
||||
mapName: "足迹地图",
|
||||
staticPreviewAlt: "{title}静态预览",
|
||||
|
|
@ -141,13 +131,8 @@ const ZH: Record<MessageKey, string> = {
|
|||
};
|
||||
|
||||
const ERROR_MESSAGES: Partial<Record<string, { en: string; zh: string }>> = {
|
||||
FM_AMAP_KEY_REQUIRED: { en: "Enter an AMap Web key in the plugin settings.", zh: "请在插件设置中填写高德 Web Key。" },
|
||||
FM_AMAP_KEY_CHANGED: { en: "Reload Obsidian before using the new AMap key.", zh: "请重新加载 Obsidian 后使用新的高德 Key。" },
|
||||
FM_AMAP_LOAD_TIMEOUT: { en: "AMap timed out while loading.", zh: "高德地图加载超时。" },
|
||||
FM_AMAP_GLOBAL_MISSING: { en: "The AMap script loaded, but the AMap API is unavailable.", zh: "高德脚本已返回但 AMap 不存在。" },
|
||||
FM_AMAP_LOAD_FAILED: { en: "AMap JS API 2.0 could not be loaded.", zh: "无法加载高德 JS API 2.0。" },
|
||||
FM_AMAP_NO_POSITION: { en: "No usable position was returned for AMap.", zh: "坐标转换未返回任何点位。" },
|
||||
FM_STANDALONE_AMAP_KEY_REQUIRED: { en: "Configure an AMap Web key in amap-config.js.", zh: "请在 amap-config.js 中配置高德 Web Key。" },
|
||||
FM_AMAP_KEY_REQUIRED: { en: "Select an AMap Web Service API key in the plugin settings.", zh: "请在插件设置中选择高德 Web 服务 API Key。" },
|
||||
FM_STANDALONE_AMAP_KEY_REQUIRED: { en: "Configure an AMap Web Service API key in amap-config.js.", zh: "请在 amap-config.js 中配置高德 Web 服务 API Key。" },
|
||||
FM_BLOCK_CONFIG_INVALID: { en: "The code block configuration is invalid.", zh: "代码块配置无效。" },
|
||||
FM_SOURCE_REQUIRED: { en: "source is required.", zh: "source 是必填路径。" },
|
||||
FM_HEIGHT_INVALID: { en: "height must be a number.", zh: "height 必须是数字。" },
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ export const normalizeCapturedAt = (date: unknown, offset: unknown): string | un
|
|||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}${suffix}`;
|
||||
}
|
||||
if (typeof date === "string") {
|
||||
const match = date.match(/^(\d{4})[:\-](\d{2})[:\-](\d{2})[ T](\d{2}):(\d{2}):(\d{2})/);
|
||||
const match = date.match(/^(\d{4})[:-](\d{2})[:-](\d{2})[ T](\d{2}):(\d{2}):(\d{2})/);
|
||||
if (match) return `${match[1]}-${match[2]}-${match[3]}T${match[4]}:${match[5]}:${match[6]}${suffix}`;
|
||||
}
|
||||
return undefined;
|
||||
|
|
|
|||
|
|
@ -1,55 +0,0 @@
|
|||
import type { AMapNamespace } from "../types/amap";
|
||||
|
||||
export interface AMapCredentials {
|
||||
key: string;
|
||||
securityJsCode?: string;
|
||||
}
|
||||
|
||||
let loadedKey: string | undefined;
|
||||
let loading: Promise<AMapNamespace> | undefined;
|
||||
|
||||
export const loadAMap = (credentials: AMapCredentials): Promise<AMapNamespace> => {
|
||||
const key = credentials.key.trim();
|
||||
if (!key) return Promise.reject(new Error("FM_AMAP_KEY_REQUIRED: 请在插件设置中填写高德 Web Key。"));
|
||||
if (window.AMap) {
|
||||
loadedKey ??= key;
|
||||
if (loadedKey !== key) return Promise.reject(new Error("FM_AMAP_KEY_CHANGED: 请重新加载 Obsidian 后使用新的高德 Key。"));
|
||||
return Promise.resolve(window.AMap);
|
||||
}
|
||||
if (loading) {
|
||||
if (loadedKey !== key) return Promise.reject(new Error("FM_AMAP_KEY_CHANGED: 请重新加载 Obsidian 后使用新的高德 Key。"));
|
||||
return loading;
|
||||
}
|
||||
loadedKey = key;
|
||||
if (credentials.securityJsCode?.trim()) {
|
||||
window._AMapSecurityConfig = { securityJsCode: credentials.securityJsCode.trim() };
|
||||
}
|
||||
const script = document.createElement("script");
|
||||
const pending = new Promise<AMapNamespace>((resolve, reject) => {
|
||||
script.id = "footprint-map-amap-jsapi";
|
||||
script.async = true;
|
||||
script.src = `https://webapi.amap.com/maps?v=2.0&key=${encodeURIComponent(key)}`;
|
||||
const timeout = window.setTimeout(() => reject(new Error("FM_AMAP_LOAD_TIMEOUT: 高德地图加载超时。")), 15_000);
|
||||
script.addEventListener("load", () => {
|
||||
window.clearTimeout(timeout);
|
||||
if (window.AMap) resolve(window.AMap);
|
||||
else reject(new Error("FM_AMAP_GLOBAL_MISSING: 高德脚本已返回但 AMap 不存在。"));
|
||||
}, { once: true });
|
||||
script.addEventListener("error", () => {
|
||||
window.clearTimeout(timeout);
|
||||
reject(new Error("FM_AMAP_LOAD_FAILED: 无法加载高德 JS API 2.0。"));
|
||||
}, { once: true });
|
||||
document.head.append(script);
|
||||
});
|
||||
let recoverable: Promise<AMapNamespace>;
|
||||
recoverable = pending.catch((error: unknown) => {
|
||||
if (loading === recoverable) {
|
||||
loading = undefined;
|
||||
loadedKey = undefined;
|
||||
}
|
||||
script.remove();
|
||||
throw error;
|
||||
});
|
||||
loading = recoverable;
|
||||
return recoverable;
|
||||
};
|
||||
|
|
@ -1,175 +0,0 @@
|
|||
import type { FootprintViewModel } from "../core/domain";
|
||||
import type { ResourceResolver } from "../platform/resource-resolver";
|
||||
import type { AMapMap, AMapMarker } from "../types/amap";
|
||||
import { loadAMap, type AMapCredentials } from "./amap-loader";
|
||||
import type { MapController } from "./controller";
|
||||
import { element } from "./dom";
|
||||
import {
|
||||
createTimeline,
|
||||
createVisitMarkerContent,
|
||||
createVisitPhotoBrowser,
|
||||
selectTimelineVisit,
|
||||
} from "./map-controller";
|
||||
import { convertCoordinates } from "./amap-coordinate-conversion";
|
||||
import type { I18n } from "../i18n";
|
||||
|
||||
export interface AMapRenderOptions {
|
||||
container: HTMLElement;
|
||||
model: FootprintViewModel;
|
||||
resourceResolver: ResourceResolver;
|
||||
resourceBasePath: string;
|
||||
credentials: AMapCredentials;
|
||||
height?: number;
|
||||
i18n: I18n;
|
||||
}
|
||||
|
||||
export class AMapController implements MapController {
|
||||
private constructor(
|
||||
private readonly root: HTMLElement,
|
||||
private readonly map: AMapMap,
|
||||
private readonly markerListeners: readonly { marker: AMapMarker; callback: (event: { target: AMapMarker }) => void }[],
|
||||
private readonly cleanupInitialFit: () => void,
|
||||
) {}
|
||||
|
||||
static async create(options: AMapRenderOptions): Promise<AMapController> {
|
||||
const AMap = await loadAMap(options.credentials);
|
||||
const positions = convertCoordinates(options.model);
|
||||
const root = element("section", "footprint-map-root footprint-map-amap-root");
|
||||
root.setAttribute("aria-label", options.model.title);
|
||||
const header = element("header", "footprint-map-header");
|
||||
const heading = element("div");
|
||||
heading.append(
|
||||
element("h3", "footprint-map-title", options.model.title),
|
||||
element("p", "footprint-map-legend", options.i18n.t("legend")),
|
||||
);
|
||||
const controls = element("div", "footprint-map-header-controls");
|
||||
const zoomOut = element("button", "footprint-map-fit", "−");
|
||||
const zoomIn = element("button", "footprint-map-fit", "+");
|
||||
const fitButton = element("button", "footprint-map-fit", options.i18n.t("fitAll"));
|
||||
for (const button of [zoomOut, zoomIn, fitButton]) button.type = "button";
|
||||
controls.append(zoomOut, zoomIn, fitButton);
|
||||
header.append(heading, controls);
|
||||
const mapElement = element("div", "footprint-map-canvas");
|
||||
mapElement.style.height = `${Math.min(960, Math.max(240, options.height ?? 420))}px`;
|
||||
const photoBrowser = createVisitPhotoBrowser(options.resourceResolver, options.resourceBasePath, options.i18n);
|
||||
root.append(header, mapElement, photoBrowser.element);
|
||||
options.container.replaceChildren(root);
|
||||
|
||||
const firstPosition = positions.get(options.model.visits[0]!.id);
|
||||
if (!firstPosition) throw new Error("FM_AMAP_NO_POSITION: 坐标转换未返回任何点位。");
|
||||
const map = new AMap.Map(mapElement, {
|
||||
center: firstPosition,
|
||||
zoom: 14,
|
||||
viewMode: "2D",
|
||||
mapStyle: "amap://styles/normal",
|
||||
resizeEnable: true,
|
||||
scrollWheel: false,
|
||||
});
|
||||
const overlays: unknown[] = [];
|
||||
let mapReady = false;
|
||||
let initialFitCompleted = false;
|
||||
let layoutTimer: number | undefined;
|
||||
let layoutFrame: number | undefined;
|
||||
const fit = (immediately: boolean): void => map.setFitView(overlays, immediately, [96, 64, 64, 64], 16);
|
||||
const cleanupInitialFit = (): void => {
|
||||
if (layoutTimer !== undefined) window.clearTimeout(layoutTimer);
|
||||
if (layoutFrame !== undefined) window.cancelAnimationFrame(layoutFrame);
|
||||
resizeObserver.disconnect();
|
||||
map.off("complete", handleMapComplete);
|
||||
map.off("resize", scheduleInitialFit);
|
||||
};
|
||||
const completeInitialFit = (): void => {
|
||||
if (initialFitCompleted || !mapReady) return;
|
||||
if (mapElement.clientWidth < 100 || mapElement.clientHeight < 100) return;
|
||||
fit(true);
|
||||
initialFitCompleted = true;
|
||||
cleanupInitialFit();
|
||||
};
|
||||
function scheduleInitialFit(): void {
|
||||
if (initialFitCompleted) return;
|
||||
if (layoutTimer !== undefined) window.clearTimeout(layoutTimer);
|
||||
if (layoutFrame !== undefined) window.cancelAnimationFrame(layoutFrame);
|
||||
layoutTimer = window.setTimeout(() => {
|
||||
layoutFrame = window.requestAnimationFrame(completeInitialFit);
|
||||
}, 100);
|
||||
}
|
||||
function handleMapComplete(): void {
|
||||
mapReady = true;
|
||||
scheduleInitialFit();
|
||||
}
|
||||
const resizeObserver = new ResizeObserver(scheduleInitialFit);
|
||||
resizeObserver.observe(mapElement);
|
||||
map.on("complete", handleMapComplete);
|
||||
map.on("resize", scheduleInitialFit);
|
||||
for (const segment of options.model.segments) {
|
||||
const from = positions.get(segment.fromId);
|
||||
const to = positions.get(segment.toId);
|
||||
if (!from || !to) continue;
|
||||
const polyline = new AMap.Polyline({
|
||||
path: [from, to],
|
||||
strokeColor: "#e2673d",
|
||||
strokeWeight: 2,
|
||||
strokeOpacity: 0.9,
|
||||
strokeStyle: "dashed",
|
||||
showDir: true,
|
||||
lineJoin: "round",
|
||||
zIndex: 40,
|
||||
});
|
||||
overlays.push(polyline);
|
||||
}
|
||||
const markerListeners: Array<{ marker: AMapMarker; callback: (event: { target: AMapMarker }) => void }> = [];
|
||||
for (const visit of options.model.visits) {
|
||||
const position = positions.get(visit.id);
|
||||
if (!position) continue;
|
||||
const markerContent = createVisitMarkerContent(visit, options.resourceResolver, options.resourceBasePath, options.i18n);
|
||||
const marker = new AMap.Marker({
|
||||
position,
|
||||
content: markerContent,
|
||||
anchor: "bottom-center",
|
||||
title: `${visit.sequence}. ${visit.displayTime} ${visit.label ?? options.i18n.t("visit")}`,
|
||||
zIndex: 100 + visit.sequence,
|
||||
});
|
||||
const callback = (): void => {
|
||||
root.querySelectorAll(".footprint-map-place-card.is-selected").forEach((node) => node.classList.remove("is-selected"));
|
||||
markerContent.classList.add("is-selected");
|
||||
selectTimelineVisit(root, visit.id);
|
||||
photoBrowser.selectVisit(visit);
|
||||
};
|
||||
marker.on("click", callback);
|
||||
markerListeners.push({ marker, callback });
|
||||
overlays.push(marker);
|
||||
}
|
||||
root.append(
|
||||
createTimeline(options.model, options.i18n),
|
||||
AMapController.createIssues(options.model, options.i18n),
|
||||
);
|
||||
const firstVisit = options.model.visits[0];
|
||||
if (firstVisit) {
|
||||
root.querySelector(".footprint-map-place-card")?.classList.add("is-selected");
|
||||
selectTimelineVisit(root, firstVisit.id);
|
||||
photoBrowser.selectVisit(firstVisit);
|
||||
}
|
||||
map.add(overlays);
|
||||
zoomOut.addEventListener("click", () => map.zoomOut());
|
||||
zoomIn.addEventListener("click", () => map.zoomIn());
|
||||
fitButton.addEventListener("click", () => fit(false));
|
||||
scheduleInitialFit();
|
||||
return new AMapController(root, map, markerListeners, cleanupInitialFit);
|
||||
}
|
||||
|
||||
private static createIssues(model: FootprintViewModel, i18n: I18n): HTMLElement {
|
||||
const region = element("aside", "footprint-map-issues");
|
||||
if (!model.issues.length) region.hidden = true;
|
||||
for (const item of model.issues) {
|
||||
region.append(element("p", undefined, `${item.code}: ${i18n.issue(item.code, item.message)}`));
|
||||
}
|
||||
return region;
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.cleanupInitialFit();
|
||||
for (const { marker, callback } of this.markerListeners) marker.off("click", callback);
|
||||
this.map.destroy();
|
||||
this.root.remove();
|
||||
}
|
||||
}
|
||||
43
src/renderer/amap-static-map.ts
Normal file
43
src/renderer/amap-static-map.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
export const AMAP_STATIC_MAP_ENDPOINT = "https://restapi.amap.com/v3/staticmap";
|
||||
|
||||
export interface AMapStaticMapRequest {
|
||||
key: string;
|
||||
longitude: number;
|
||||
latitude: number;
|
||||
leafletZoom: number;
|
||||
viewportWidth: number;
|
||||
viewportHeight: number;
|
||||
}
|
||||
|
||||
export interface AMapStaticMapImage {
|
||||
url: string;
|
||||
requestWidth: number;
|
||||
requestHeight: number;
|
||||
}
|
||||
|
||||
const clampInteger = (value: number, minimum: number, maximum: number): number => (
|
||||
Math.min(maximum, Math.max(minimum, Math.round(value)))
|
||||
);
|
||||
|
||||
/**
|
||||
* AMap scale=2 returns twice the requested pixel dimensions and renders one
|
||||
* zoom level closer. Request half of the CSS viewport and one lower zoom so
|
||||
* the returned image aligns with Leaflet's current view.
|
||||
*/
|
||||
export const createAMapStaticMapImage = (request: AMapStaticMapRequest): AMapStaticMapImage => {
|
||||
const requestWidth = clampInteger(request.viewportWidth / 2, 64, 1024);
|
||||
const requestHeight = clampInteger(request.viewportHeight / 2, 64, 1024);
|
||||
const amapZoom = clampInteger(request.leafletZoom - 1, 1, 17);
|
||||
const parameters = new URLSearchParams({
|
||||
key: request.key,
|
||||
location: `${request.longitude.toFixed(6)},${request.latitude.toFixed(6)}`,
|
||||
zoom: String(amapZoom),
|
||||
size: `${requestWidth}*${requestHeight}`,
|
||||
scale: "2",
|
||||
});
|
||||
return {
|
||||
url: `${AMAP_STATIC_MAP_ENDPOINT}?${parameters.toString()}`,
|
||||
requestWidth,
|
||||
requestHeight,
|
||||
};
|
||||
};
|
||||
|
|
@ -3,7 +3,7 @@ export const element = <K extends keyof HTMLElementTagNameMap>(
|
|||
className?: string,
|
||||
text?: string,
|
||||
): HTMLElementTagNameMap[K] => {
|
||||
const node = document.createElement(tag);
|
||||
const node = createEl(tag);
|
||||
if (className) node.className = className;
|
||||
if (text !== undefined) node.textContent = text;
|
||||
return node;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import L, {
|
||||
type ImageOverlay,
|
||||
type LatLngBoundsExpression,
|
||||
type Map as LeafletMap,
|
||||
type Marker,
|
||||
|
|
@ -10,6 +11,16 @@ import type { TileProviderConfig } from "../platform/tile-provider";
|
|||
import type { I18n } from "../i18n";
|
||||
import { element } from "./dom";
|
||||
import type { MapController } from "./controller";
|
||||
import { createAMapStaticMapImage } from "./amap-static-map";
|
||||
|
||||
export type CoordinateTransform = (
|
||||
longitude: number,
|
||||
latitude: number,
|
||||
) => readonly [longitude: number, latitude: number];
|
||||
|
||||
export interface AMapStaticMapConfig {
|
||||
key: string;
|
||||
}
|
||||
|
||||
export interface MapRenderOptions {
|
||||
container: HTMLElement;
|
||||
|
|
@ -17,14 +28,21 @@ export interface MapRenderOptions {
|
|||
resourceResolver: ResourceResolver;
|
||||
resourceBasePath: string;
|
||||
tileProvider?: TileProviderConfig | null;
|
||||
coordinateTransform?: CoordinateTransform;
|
||||
amapStaticMap?: AMapStaticMapConfig;
|
||||
height?: number;
|
||||
i18n: I18n;
|
||||
}
|
||||
|
||||
const toLatLng = (visit: VisitViewModel): [number, number] => [
|
||||
visit.coordinates.latitude,
|
||||
visit.coordinates.longitude,
|
||||
];
|
||||
const toLatLng = (
|
||||
coordinates: { longitude: number; latitude: number },
|
||||
transform?: CoordinateTransform,
|
||||
): [number, number] => {
|
||||
const [longitude, latitude] = transform
|
||||
? transform(coordinates.longitude, coordinates.latitude)
|
||||
: [coordinates.longitude, coordinates.latitude];
|
||||
return [latitude, longitude];
|
||||
};
|
||||
|
||||
export const createVisitMarkerContent = (
|
||||
visit: VisitViewModel,
|
||||
|
|
@ -125,26 +143,35 @@ export const selectTimelineVisit = (root: HTMLElement, visitId: string): void =>
|
|||
}
|
||||
};
|
||||
|
||||
const segmentAngle = (map: LeafletMap, segment: SequenceSegment): number => {
|
||||
const from = map.latLngToContainerPoint([segment.from.latitude, segment.from.longitude]);
|
||||
const to = map.latLngToContainerPoint([segment.to.latitude, segment.to.longitude]);
|
||||
interface DisplaySegment {
|
||||
source: SequenceSegment;
|
||||
from: [number, number];
|
||||
to: [number, number];
|
||||
}
|
||||
|
||||
const segmentAngle = (map: LeafletMap, segment: DisplaySegment): number => {
|
||||
const from = map.latLngToContainerPoint(segment.from);
|
||||
const to = map.latLngToContainerPoint(segment.to);
|
||||
return Math.atan2(to.y - from.y, to.x - from.x) * (180 / Math.PI);
|
||||
};
|
||||
|
||||
const segmentMidpoint = (segment: SequenceSegment): [number, number] => [
|
||||
(segment.from.latitude + segment.to.latitude) / 2,
|
||||
(segment.from.longitude + segment.to.longitude) / 2,
|
||||
const segmentMidpoint = (segment: DisplaySegment): [number, number] => [
|
||||
(segment.from[0] + segment.to[0]) / 2,
|
||||
(segment.from[1] + segment.to[1]) / 2,
|
||||
];
|
||||
|
||||
export class FootprintMapController implements MapController {
|
||||
private readonly map: LeafletMap;
|
||||
private readonly root: HTMLElement;
|
||||
private readonly issuesRegion: HTMLElement;
|
||||
private readonly arrowMarkers: Array<{ marker: Marker; segment: SequenceSegment }> = [];
|
||||
private readonly arrowMarkers: Array<{ marker: Marker; segment: DisplaySegment }> = [];
|
||||
private readonly updateArrows: () => void;
|
||||
private readonly cleanupInitialFit: () => void;
|
||||
private cleanupStaticMap: () => void = () => undefined;
|
||||
private tileLayer: TileLayer | undefined;
|
||||
private staticImageLayer: ImageOverlay | undefined;
|
||||
private tileErrorCount = 0;
|
||||
private basemapWarningShown = false;
|
||||
|
||||
constructor(private readonly options: MapRenderOptions) {
|
||||
const { container, model, i18n } = options;
|
||||
|
|
@ -173,7 +200,9 @@ export class FootprintMapController implements MapController {
|
|||
zoomControl: true,
|
||||
attributionControl: true,
|
||||
scrollWheelZoom: false,
|
||||
zoomSnap: 0.25,
|
||||
zoomSnap: options.amapStaticMap ? 1 : 0.25,
|
||||
minZoom: options.amapStaticMap ? 2 : undefined,
|
||||
maxZoom: options.amapStaticMap ? 18 : undefined,
|
||||
});
|
||||
if (options.tileProvider) {
|
||||
this.tileLayer = L.tileLayer(options.tileProvider.urlTemplate, {
|
||||
|
|
@ -184,26 +213,36 @@ export class FootprintMapController implements MapController {
|
|||
this.tileLayer.addTo(this.map);
|
||||
}
|
||||
|
||||
const bounds: LatLngBoundsExpression = model.visits.map(toLatLng);
|
||||
if (options.amapStaticMap) {
|
||||
this.configureAMapStaticBasemap(mapElement, options.amapStaticMap);
|
||||
}
|
||||
|
||||
const positions = new Map(model.visits.map((visit) => [
|
||||
visit.id,
|
||||
toLatLng(visit.coordinates, options.coordinateTransform),
|
||||
]));
|
||||
const bounds: LatLngBoundsExpression = model.visits.map((visit) => positions.get(visit.id)!);
|
||||
for (const segment of model.segments) {
|
||||
const displaySegment: DisplaySegment = {
|
||||
source: segment,
|
||||
from: toLatLng(segment.from, options.coordinateTransform),
|
||||
to: toLatLng(segment.to, options.coordinateTransform),
|
||||
};
|
||||
L.polyline(
|
||||
[
|
||||
[segment.from.latitude, segment.from.longitude],
|
||||
[segment.to.latitude, segment.to.longitude],
|
||||
],
|
||||
[displaySegment.from, displaySegment.to],
|
||||
{ color: "#e2673d", weight: 2, opacity: 0.9, dashArray: "9 9", lineCap: "round" },
|
||||
).addTo(this.map);
|
||||
const marker = L.marker(segmentMidpoint(segment), {
|
||||
const marker = L.marker(segmentMidpoint(displaySegment), {
|
||||
interactive: false,
|
||||
keyboard: false,
|
||||
icon: L.divIcon({ className: "footprint-map-arrow", html: "<span>➤</span>", iconSize: [14, 14], iconAnchor: [7, 7] }),
|
||||
}).addTo(this.map);
|
||||
this.arrowMarkers.push({ marker, segment });
|
||||
this.arrowMarkers.push({ marker, segment: displaySegment });
|
||||
}
|
||||
|
||||
for (const visit of model.visits) {
|
||||
const markerContent = createVisitMarkerContent(visit, options.resourceResolver, options.resourceBasePath, i18n);
|
||||
const marker = L.marker(toLatLng(visit), {
|
||||
const marker = L.marker(positions.get(visit.id)!, {
|
||||
icon: L.divIcon({
|
||||
className: "footprint-map-marker-host",
|
||||
html: markerContent,
|
||||
|
|
@ -230,7 +269,7 @@ export class FootprintMapController implements MapController {
|
|||
}
|
||||
|
||||
const fit = (): void => {
|
||||
if (model.visits.length === 1) this.map.setView(toLatLng(model.visits[0]!), 14);
|
||||
if (model.visits.length === 1) this.map.setView(positions.get(model.visits[0]!.id)!, 14);
|
||||
else this.map.fitBounds(bounds, {
|
||||
paddingTopLeft: [48, 104],
|
||||
paddingBottomRight: [48, 24],
|
||||
|
|
@ -280,18 +319,83 @@ export class FootprintMapController implements MapController {
|
|||
|
||||
}
|
||||
|
||||
private readonly handleTileError = (): void => {
|
||||
this.tileErrorCount += 1;
|
||||
if (this.tileErrorCount < 3 || !this.tileLayer) return;
|
||||
this.tileLayer.off("tileerror", this.handleTileError);
|
||||
this.tileLayer.remove();
|
||||
this.tileLayer = undefined;
|
||||
private configureAMapStaticBasemap(
|
||||
mapElement: HTMLElement,
|
||||
config: AMapStaticMapConfig,
|
||||
): void {
|
||||
const paneName = "footprint-map-amap-static-basemap";
|
||||
const pane = this.map.createPane(paneName);
|
||||
pane.style.zIndex = "200";
|
||||
pane.style.pointerEvents = "none";
|
||||
this.map.attributionControl.addAttribution('<a href="https://www.amap.com/">AMap</a>');
|
||||
|
||||
let timer: number | undefined;
|
||||
let requestSequence = 0;
|
||||
let destroyed = false;
|
||||
const refresh = (): void => {
|
||||
if (destroyed || mapElement.clientWidth < 100 || mapElement.clientHeight < 100) return;
|
||||
const center = this.map.getCenter();
|
||||
const image = createAMapStaticMapImage({
|
||||
key: config.key,
|
||||
longitude: center.lng,
|
||||
latitude: center.lat,
|
||||
leafletZoom: this.map.getZoom(),
|
||||
viewportWidth: mapElement.clientWidth,
|
||||
viewportHeight: mapElement.clientHeight,
|
||||
});
|
||||
const sequence = ++requestSequence;
|
||||
const nextLayer = L.imageOverlay(image.url, this.map.getBounds(), {
|
||||
pane: paneName,
|
||||
interactive: false,
|
||||
});
|
||||
nextLayer.once("load", () => {
|
||||
if (destroyed || sequence !== requestSequence) {
|
||||
nextLayer.remove();
|
||||
return;
|
||||
}
|
||||
this.staticImageLayer?.remove();
|
||||
this.staticImageLayer = nextLayer;
|
||||
});
|
||||
nextLayer.once("error", () => {
|
||||
nextLayer.remove();
|
||||
if (!destroyed && sequence === requestSequence) this.showBasemapWarning();
|
||||
});
|
||||
nextLayer.addTo(this.map);
|
||||
};
|
||||
const schedule = (): void => {
|
||||
if (timer !== undefined) window.clearTimeout(timer);
|
||||
timer = window.setTimeout(refresh, 180);
|
||||
};
|
||||
this.map.on("moveend zoomend", schedule);
|
||||
this.map.whenReady(schedule);
|
||||
this.cleanupStaticMap = () => {
|
||||
destroyed = true;
|
||||
requestSequence += 1;
|
||||
if (timer !== undefined) window.clearTimeout(timer);
|
||||
this.map.off("moveend zoomend", schedule);
|
||||
this.staticImageLayer?.remove();
|
||||
this.staticImageLayer = undefined;
|
||||
};
|
||||
}
|
||||
|
||||
private showBasemapWarning(): void {
|
||||
if (this.basemapWarningShown) return;
|
||||
this.basemapWarningShown = true;
|
||||
this.issuesRegion.hidden = false;
|
||||
this.issuesRegion.append(element(
|
||||
"p",
|
||||
"footprint-map-basemap-warning",
|
||||
this.options.i18n.t("basemapUnavailable"),
|
||||
));
|
||||
}
|
||||
|
||||
private readonly handleTileError = (): void => {
|
||||
this.tileErrorCount += 1;
|
||||
if (this.tileErrorCount < 3 || !this.tileLayer) return;
|
||||
this.tileLayer.off("tileerror", this.handleTileError);
|
||||
this.tileLayer.remove();
|
||||
this.tileLayer = undefined;
|
||||
this.showBasemapWarning();
|
||||
};
|
||||
|
||||
private createIssues(model: FootprintViewModel): HTMLElement {
|
||||
|
|
@ -305,6 +409,7 @@ export class FootprintMapController implements MapController {
|
|||
|
||||
destroy(): void {
|
||||
this.cleanupInitialFit();
|
||||
this.cleanupStaticMap();
|
||||
this.tileLayer?.off("tileerror", this.handleTileError);
|
||||
this.map.off("zoomend moveend", this.updateArrows);
|
||||
this.map.remove();
|
||||
|
|
|
|||
50
src/types/amap.d.ts
vendored
50
src/types/amap.d.ts
vendored
|
|
@ -1,50 +0,0 @@
|
|||
export interface AMapLngLat {
|
||||
getLng(): number;
|
||||
getLat(): number;
|
||||
}
|
||||
|
||||
export interface AMapEventTarget {
|
||||
on(event: string, callback: (event: { target: AMapMarker }) => void): void;
|
||||
off(event: string, callback: (event: { target: AMapMarker }) => void): void;
|
||||
}
|
||||
|
||||
export interface AMapMarker extends AMapEventTarget {
|
||||
getPosition(): AMapLngLat;
|
||||
}
|
||||
|
||||
export interface AMapInfoWindow {
|
||||
open(map: AMapMap, position: AMapLngLat | readonly [number, number]): void;
|
||||
close(): void;
|
||||
}
|
||||
|
||||
export interface AMapMap {
|
||||
add(overlays: unknown | readonly unknown[]): void;
|
||||
destroy(): void;
|
||||
on(event: "complete" | "resize", callback: () => void): void;
|
||||
off(event: "complete" | "resize", callback: () => void): void;
|
||||
setFitView(overlays?: readonly unknown[], immediately?: boolean, avoid?: readonly number[], maxZoom?: number): void;
|
||||
zoomIn(): void;
|
||||
zoomOut(): void;
|
||||
}
|
||||
|
||||
export interface AMapNamespace {
|
||||
Map: new (container: HTMLElement, options: Record<string, unknown>) => AMapMap;
|
||||
Marker: new (options: Record<string, unknown>) => AMapMarker;
|
||||
Polyline: new (options: Record<string, unknown>) => unknown;
|
||||
InfoWindow: new (options: Record<string, unknown>) => AMapInfoWindow;
|
||||
Pixel: new (x: number, y: number) => unknown;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
AMap?: AMapNamespace;
|
||||
_AMapSecurityConfig?: {
|
||||
securityJsCode?: string;
|
||||
serviceHost?: string;
|
||||
};
|
||||
FOOTPRINT_MAP_AMAP_CONFIG?: {
|
||||
key: string;
|
||||
securityJsCode?: string;
|
||||
};
|
||||
}
|
||||
}
|
||||
9
src/types/standalone.d.ts
vendored
Normal file
9
src/types/standalone.d.ts
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
export {};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
FOOTPRINT_MAP_AMAP_CONFIG?: {
|
||||
webServiceKey: string;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,3 @@
|
|||
window.FOOTPRINT_MAP_AMAP_CONFIG = {
|
||||
key: "",
|
||||
securityJsCode: "",
|
||||
webServiceKey: "",
|
||||
};
|
||||
|
|
|
|||
18
styles.css
18
styles.css
|
|
@ -7,7 +7,7 @@
|
|||
--fm-panel: var(--background-primary, #fffdf8);
|
||||
--fm-border: var(--background-modifier-border, #ded8cc);
|
||||
color: var(--fm-ink);
|
||||
font-family: var(--font-interface, ui-sans-serif, system-ui, sans-serif);
|
||||
font-family: var(--font-interface, system-ui, sans-serif);
|
||||
}
|
||||
|
||||
.footprint-map-header,
|
||||
|
|
@ -22,7 +22,7 @@
|
|||
.footprint-map-title,
|
||||
.footprint-map-header p { margin: 0; }
|
||||
.footprint-map-title { font-size: 1.15rem; }
|
||||
.footprint-map-legend { color: var(--fm-muted); font-size: .78rem; margin-top: 3px !important; }
|
||||
.footprint-map-header .footprint-map-legend { color: var(--fm-muted); font-size: .78rem; margin-top: 3px; }
|
||||
|
||||
.footprint-map-fit,
|
||||
.footprint-map-source-form button {
|
||||
|
|
@ -73,14 +73,14 @@
|
|||
width: 1.5cm;
|
||||
}
|
||||
.footprint-map-root .footprint-map-place-preview > img {
|
||||
display: block !important;
|
||||
height: 100% !important;
|
||||
max-height: none !important;
|
||||
max-width: none !important;
|
||||
object-fit: cover !important;
|
||||
object-position: center center !important;
|
||||
display: block;
|
||||
height: 100%;
|
||||
max-height: none;
|
||||
max-width: none;
|
||||
object-fit: cover;
|
||||
object-position: center center;
|
||||
pointer-events: none;
|
||||
width: 100% !important;
|
||||
width: 100%;
|
||||
}
|
||||
.footprint-map-place-fallback { font-size: 16px; font-weight: 800; }
|
||||
.footprint-map-place-sequence {
|
||||
|
|
|
|||
41
tests/unit/amap-static-map.test.ts
Normal file
41
tests/unit/amap-static-map.test.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
AMAP_STATIC_MAP_ENDPOINT,
|
||||
createAMapStaticMapImage,
|
||||
} from "../../src/renderer/amap-static-map";
|
||||
|
||||
describe("AMap static map requests", () => {
|
||||
it("uses the literal Web Service endpoint and scale-adjusted viewport", () => {
|
||||
const image = createAMapStaticMapImage({
|
||||
key: "example-web-service-key",
|
||||
longitude: 102.55123456,
|
||||
latitude: 24.35678912,
|
||||
leafletZoom: 14,
|
||||
viewportWidth: 1400,
|
||||
viewportHeight: 840,
|
||||
});
|
||||
const url = new URL(image.url);
|
||||
|
||||
expect(url.origin + url.pathname).toBe(AMAP_STATIC_MAP_ENDPOINT);
|
||||
expect(url.searchParams.get("key")).toBe("example-web-service-key");
|
||||
expect(url.searchParams.get("location")).toBe("102.551235,24.356789");
|
||||
expect(url.searchParams.get("zoom")).toBe("13");
|
||||
expect(url.searchParams.get("size")).toBe("700*420");
|
||||
expect(url.searchParams.get("scale")).toBe("2");
|
||||
});
|
||||
|
||||
it("clamps the API dimensions and zoom to supported values", () => {
|
||||
const image = createAMapStaticMapImage({
|
||||
key: "test",
|
||||
longitude: 120,
|
||||
latitude: 30,
|
||||
leafletZoom: 30,
|
||||
viewportWidth: 5000,
|
||||
viewportHeight: 50,
|
||||
});
|
||||
const url = new URL(image.url);
|
||||
|
||||
expect(url.searchParams.get("zoom")).toBe("17");
|
||||
expect(url.searchParams.get("size")).toBe("1024*64");
|
||||
});
|
||||
});
|
||||
17
trd.md
17
trd.md
|
|
@ -1,12 +1,12 @@
|
|||
# TRD:Footprint Map
|
||||
|
||||
**职责:** 将 REQ-004 v0.13 转化为可实施的技术基线,定义技术选型、代码层级、核心接口、数据格式、渲染流程、错误模型、测试范围与发布产物。
|
||||
**职责:** 将 REQ-004 v0.22 转化为可实施的技术基线,定义技术选型、代码层级、核心接口、数据格式、渲染流程、错误模型、测试范围与发布产物。
|
||||
|
||||
## 文档状态
|
||||
|
||||
- 状态:技术设计草案,待实施前审阅。
|
||||
- TRD 版本:v0.1。
|
||||
- 需求基线:[[../../Requirements/REQ-004-可移植Markdown互动足迹地图|REQ-004 v0.13]]。
|
||||
- 需求基线:[[../../Requirements/REQ-004-可移植Markdown互动足迹地图|REQ-004 v0.22]]。
|
||||
- 项目设计:[[design|设计图]]、[[interaction|交互图]]、[[visual|效果图]]。
|
||||
- 实施状态:MVP 源码、自动化测试、Obsidian 包与 standalone 产物已创建;待真实宿主和照片样本验收。
|
||||
|
||||
|
|
@ -45,15 +45,16 @@
|
|||
- 渲染层不向业务层暴露 Leaflet 类型,方便未来替换渲染库。
|
||||
- 选型依据:[Leaflet API](https://leafletjs.com/reference)、[Leaflet GeoJSON](https://leafletjs.com/examples/geojson/)。
|
||||
|
||||
### ADR-002A:高德 JS API 2.0 作为中国大陆可选渲染器
|
||||
### ADR-002A:高德静态地图作为中国大陆可选底图
|
||||
|
||||
- 通过 `tileProvider: amap` 显式选择,不破坏现有 `osm` 配置。
|
||||
- Key 与可选 `securityJsCode` 只保存在宿主设置;源码、Markdown、GeoJSON 和 release 包不写入凭据。
|
||||
- 使用官方 Web 服务字面量端点 `https://restapi.amap.com/v3/staticmap`,不动态加载第三方 JavaScript,不拼接或隐藏域名。
|
||||
- 只接受“Web 服务 API Key”,凭据值保存在 Obsidian SecretStorage;源码、Markdown、GeoJSON 和 release 包不写入凭据。旧 JS API Key 与安全码不自动复用。
|
||||
- GeoJSON 继续保存 WGS84;渲染时由插件内的纯 TypeScript 算法转换为 GCJ-02,避免依赖高德在线坐标转换服务。
|
||||
- 照片 Marker、地图外横向照片浏览区、虚线 Polyline、方向箭头与视野适配由高德适配器实现,领域层不依赖高德类型。
|
||||
- 高德脚本与底图属于显式联网能力,失败时进入现有错误/静态图降级路径;坐标转换可离线完成。
|
||||
- 高德首次视野适配等待地图 `complete` 与容器非零稳定尺寸,并以 `ResizeObserver` 处理 Obsidian 阅读模式的延迟布局;首次成功后停止自动适配,避免覆盖用户操作。
|
||||
- 作为 Markdown 内嵌组件,高德设置 `scrollWheel: false`、Leaflet 设置 `scrollWheelZoom: false`,让桌面滚动事件继续服务于文档浏览,同时保留主动地图交互。
|
||||
- 静态图作为 Leaflet `ImageOverlay` 的底层 pane;照片 Marker、地图外横向照片浏览区、虚线 Polyline、方向箭头与视野适配复用同一 Leaflet 控制器。
|
||||
- 静态图仅在初始布局完成以及 `moveend` / `zoomend` 后防抖刷新,使用 `scale=2` 与受限的图片尺寸,不预取、不批量缓存。
|
||||
- 请求失败时移除无效底图并显示警告,本地点位、连线、箭头、时间轴和照片仍可使用。
|
||||
- 作为 Markdown 内嵌组件,Leaflet 设置 `scrollWheelZoom: false`,让桌面滚动事件继续服务于文档浏览,同时保留主动地图交互。
|
||||
|
||||
### ADR-003:`exifr` 作为 EXIF 解析实现
|
||||
|
||||
|
|
|
|||
|
|
@ -8,5 +8,6 @@
|
|||
"0.2.5": "1.5.0",
|
||||
"0.2.6": "1.5.0",
|
||||
"0.3.0": "1.5.0",
|
||||
"0.4.0": "1.11.5"
|
||||
"0.4.0": "1.11.5",
|
||||
"0.4.1": "1.11.5"
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue