mirror of
https://github.com/evanwong89/footprint-map.git
synced 2026-07-22 08:33:04 +00:00
Improve map zoom gestures and quick-start docs
This commit is contained in:
parent
1bddcc8372
commit
94e91882f2
12 changed files with 114 additions and 29 deletions
37
README.md
37
README.md
|
|
@ -4,6 +4,19 @@ Footprint Map renders a local, time-ordered travel diary from photo locations in
|
|||
|
||||
中文说明见 [简体中文](#简体中文)。
|
||||
|
||||
## Quick start
|
||||
|
||||
1. Install and enable the Footprint Map plugin.
|
||||
2. Open the Obsidian note where you want to generate a Footprint Map.
|
||||
3. Press `Command + P` to open the Command Palette, search for `Footprint`, and select **Footprint Map: Generate footprint from photos in the current note**. A footprint map will be generated in the original note, as shown in the preview.
|
||||
|
||||
### Notes
|
||||
|
||||
The following images may not appear in the Footprint Map:
|
||||
|
||||
1. Images downloaded from the internet.
|
||||
2. Photos taken without location information enabled.
|
||||
|
||||

|
||||
|
||||
*Visualize a day's photo journey on an interactive map, with time-ordered places, photo galleries, and a synchronized timeline.*
|
||||
|
|
@ -46,15 +59,6 @@ After the plugin is accepted into the Obsidian community directory:
|
|||
|
||||
Do not copy another person's `data.json`; it contains vault-specific preferences and secret references.
|
||||
|
||||
## Quick start
|
||||
|
||||
1. Open a Markdown note that embeds local JPEG, HEIC, or PNG photos.
|
||||
2. Run **Generate footprint from photos in the current note** from the command palette.
|
||||
3. Review the generated `.footprint.geojson` file and the `footprint-map` code block appended to the note.
|
||||
4. Run **Export a static footprint preview** if you also want an SVG fallback.
|
||||
|
||||
Only valid photo metadata is imported. Missing GPS, missing timezone information, and damaged files are reported rather than guessed.
|
||||
|
||||
## Markdown format
|
||||
|
||||
````markdown
|
||||
|
|
@ -159,11 +163,16 @@ Footprint Map 从 Obsidian 笔记引用的本地照片中读取 GPS 与带时区
|
|||
|
||||
### 快速使用
|
||||
|
||||
1. 安装并启用插件。
|
||||
2. 打开引用了本地 JPEG、HEIC 或 PNG 照片的笔记。
|
||||
3. 在命令面板执行“从当前笔记的照片生成足迹”。
|
||||
4. 插件会在笔记旁生成 GeoJSON,并在笔记中加入 `footprint-map` 代码块。
|
||||
5. 如需静态降级图,再执行“导出当前笔记的静态足迹图”。
|
||||
1. 安装并启用 Footprint Map 插件。
|
||||
2. 打开 Obsidian 中想要生成 Footprint Map 的笔记。
|
||||
3. 按 `Command + P` 打开命令面板,搜索 `Footprint`,选择 **Footprint Map:从当前笔记的照片生成足迹**,原笔记中即可生成足迹(效果如预览图)。
|
||||
|
||||
### 注意事项
|
||||
|
||||
以下情况可能会导致图片无法展示在 Footprint Map 的结果中:
|
||||
|
||||
1. 网络下载的图片。
|
||||
2. 拍摄时未启用位置信息的照片。
|
||||
|
||||
默认底图为 OpenStreetMap,无需 Key。中国大陆用户可以切换高德静态地图,并在 Obsidian 安全存储中选择或创建包含“Web 服务 API Key”的凭据;旧的 Web 端(JS API)Key 不能代替该 Key。高德请求只在视野稳定或拖动、缩放结束后防抖发起;点位、连线、照片和时间轴仍在本地渲染。高级用户也可以使用自定义瓦片,或设置 `tiles: false` 完全关闭在线底图。
|
||||
|
||||
|
|
|
|||
4
index.md
4
index.md
|
|
@ -4,12 +4,12 @@
|
|||
|
||||
## 项目状态
|
||||
|
||||
- 状态:MVP 已实现并通过类型检查、自动化测试与生产构建;待 Obsidian Desktop/Mobile 和真实照片样本验收。
|
||||
- 状态:MVP 已实现并通过类型检查、自动化测试、生产构建与本地 Browser 页面滚动验证;待 Obsidian Desktop/Mobile、物理触控板捏合手感和真实照片样本验收。
|
||||
- 关联需求:[[../../Requirements/REQ-004-可移植Markdown互动足迹地图|REQ-004:可移植 Markdown 互动足迹地图]]
|
||||
- 当前范围:Obsidian 日记内嵌互动地图、Leaflet/OpenStreetMap 与高德静态地图底图、照片缩略图点位与地图外横向照片浏览、200 米连续照片聚合、Vault 内 JPEG/HEIC/PNG 的 TypeScript EXIF 解析、GeoJSON 通用数据、独立 HTML 查看器与静态降级图。
|
||||
- 项目边界:独立编写与构建,不依赖工作区内其他本地项目。
|
||||
- 非目标:操作系统后台任务、Apple 原生框架、原生配套 App 及 Python/Swift 外部运行时。
|
||||
- 实施基线:单日地图、Leaflet、外部 GeoJSON、可关闭的 OpenStreetMap 在线瓦片、缺失时区不猜测、HEIC 无法显示时占位。
|
||||
- 实施基线:单日地图、Leaflet、外部 GeoJSON、可关闭的 OpenStreetMap 在线瓦片、普通滚动优先浏览文档且修饰键/捏合手势缩放地图、缺失时区不猜测、HEIC 无法显示时占位。
|
||||
|
||||
## 项目文档
|
||||
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ stateDiagram-v2
|
|||
## 地图内交互
|
||||
|
||||
- **拖动:** 改变当前视野,不改变足迹数据。
|
||||
- **缩放:** 通过地图 `+`/`−` 控件或移动端双指手势调整比例;桌面滚轮和触控板滚动默认留给 Markdown 页面,不缩放地图。
|
||||
- **缩放:** 通过地图 `+`/`−` 控件、移动端双指手势、Mac 触控板捏合,或按住 `Command`/`Ctrl` 后滚动调整比例;无修饰键双指滚动或鼠标滚轮继续滚动 Markdown 页面,不改变地图级别。
|
||||
- **适配视野:** 将所有有效点位恢复到当前地图容器内。
|
||||
- **到访点:** 以首图作为 1.5 cm 方形缩略图,序号徽标圆心位于卡片右上角并主要处于图片外侧,底栏只显示第一张照片时间;点击切换选中态。
|
||||
- **照片:** 不在地图上打开弹层;地图下方独立区域横向排列所选地点的全部照片,单图高度不超过 4 cm,可左右滑动。
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ const EN = {
|
|||
customTileMaxZoom: "Custom tile maximum zoom",
|
||||
customTileMaxZoomDesc: "Set this to the maximum zoom supported by the provider.",
|
||||
legend: "Dashed lines and arrows show time order only, not the actual route taken.",
|
||||
zoomHint: "Pinch or hold ⌘/Ctrl while scrolling to zoom the map.",
|
||||
fitAll: "Fit all places",
|
||||
placeAria: "Place {sequence}, {time}",
|
||||
primaryPhotoAlt: "First photo from place {sequence}",
|
||||
|
|
@ -89,6 +90,7 @@ const ZH: Record<MessageKey, string> = {
|
|||
customTileMaxZoom: "自定义瓦片最大缩放级别",
|
||||
customTileMaxZoomDesc: "按服务商实际支持范围设置。",
|
||||
legend: "虚线和箭头仅表示时间顺序,不表示实际街道路线。",
|
||||
zoomHint: "捏合,或按住 ⌘/Ctrl 滚动以缩放地图。",
|
||||
fitAll: "适配全部点位",
|
||||
placeAria: "地点 {sequence},{time}",
|
||||
primaryPhotoAlt: "地点 {sequence} 的第一张照片",
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ import type { I18n } from "../i18n";
|
|||
import { element } from "./dom";
|
||||
import type { MapController } from "./controller";
|
||||
import { createAMapStaticMapImage } from "./amap-static-map";
|
||||
import { createLeafletMapOptions } from "./map-options";
|
||||
import { gateWheelZoom } from "./wheel-zoom-gate";
|
||||
|
||||
export type CoordinateTransform = (
|
||||
longitude: number,
|
||||
|
|
@ -167,6 +169,7 @@ export class FootprintMapController implements MapController {
|
|||
private readonly arrowMarkers: Array<{ marker: Marker; segment: DisplaySegment }> = [];
|
||||
private readonly updateArrows: () => void;
|
||||
private readonly cleanupInitialFit: () => void;
|
||||
private readonly cleanupWheelZoomGate: () => void;
|
||||
private cleanupStaticMap: () => void = () => undefined;
|
||||
private tileLayer: TileLayer | undefined;
|
||||
private staticImageLayer: ImageOverlay | undefined;
|
||||
|
|
@ -184,6 +187,7 @@ export class FootprintMapController implements MapController {
|
|||
heading.append(
|
||||
element("h3", "footprint-map-title", model.title),
|
||||
element("p", "footprint-map-legend", i18n.t("legend")),
|
||||
element("p", "footprint-map-zoom-hint", i18n.t("zoomHint")),
|
||||
);
|
||||
const fitButton = element("button", "footprint-map-fit", i18n.t("fitAll"));
|
||||
fitButton.type = "button";
|
||||
|
|
@ -196,14 +200,9 @@ export class FootprintMapController implements MapController {
|
|||
this.issuesRegion = this.createIssues(model);
|
||||
container.append(this.root);
|
||||
|
||||
this.map = L.map(mapElement, {
|
||||
zoomControl: true,
|
||||
attributionControl: true,
|
||||
scrollWheelZoom: false,
|
||||
zoomSnap: options.amapStaticMap ? 1 : 0.25,
|
||||
minZoom: options.amapStaticMap ? 2 : undefined,
|
||||
maxZoom: options.amapStaticMap ? 18 : undefined,
|
||||
});
|
||||
mapElement.addEventListener("wheel", gateWheelZoom, { capture: true, passive: true });
|
||||
this.cleanupWheelZoomGate = () => mapElement.removeEventListener("wheel", gateWheelZoom, true);
|
||||
this.map = L.map(mapElement, createLeafletMapOptions(Boolean(options.amapStaticMap)));
|
||||
if (options.tileProvider) {
|
||||
this.tileLayer = L.tileLayer(options.tileProvider.urlTemplate, {
|
||||
attribution: options.tileProvider.attribution,
|
||||
|
|
@ -408,6 +407,7 @@ export class FootprintMapController implements MapController {
|
|||
|
||||
destroy(): void {
|
||||
this.cleanupInitialFit();
|
||||
this.cleanupWheelZoomGate();
|
||||
this.cleanupStaticMap();
|
||||
this.tileLayer?.off("tileerror", this.handleTileError);
|
||||
this.map.off("zoomend moveend", this.updateArrows);
|
||||
|
|
|
|||
11
src/renderer/map-options.ts
Normal file
11
src/renderer/map-options.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import type { MapOptions } from "leaflet";
|
||||
|
||||
export const createLeafletMapOptions = (usesAMapStaticBasemap: boolean): MapOptions => ({
|
||||
zoomControl: true,
|
||||
attributionControl: true,
|
||||
scrollWheelZoom: true,
|
||||
touchZoom: true,
|
||||
zoomSnap: usesAMapStaticBasemap ? 1 : 0.25,
|
||||
minZoom: usesAMapStaticBasemap ? 2 : undefined,
|
||||
maxZoom: usesAMapStaticBasemap ? 18 : undefined,
|
||||
});
|
||||
17
src/renderer/wheel-zoom-gate.ts
Normal file
17
src/renderer/wheel-zoom-gate.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
export interface WheelZoomEvent {
|
||||
readonly ctrlKey: boolean;
|
||||
readonly metaKey: boolean;
|
||||
stopImmediatePropagation(): void;
|
||||
}
|
||||
|
||||
export const isIntentionalWheelZoom = (
|
||||
event: Pick<WheelZoomEvent, "ctrlKey" | "metaKey">,
|
||||
): boolean => event.ctrlKey || event.metaKey;
|
||||
|
||||
export const gateWheelZoom = (event: WheelZoomEvent): void => {
|
||||
if (isIntentionalWheelZoom(event)) return;
|
||||
|
||||
// Do not prevent the browser's default action: the surrounding document must
|
||||
// continue scrolling. Stop only JavaScript propagation before Leaflet sees it.
|
||||
event.stopImmediatePropagation();
|
||||
};
|
||||
|
|
@ -27,7 +27,9 @@
|
|||
.footprint-map-title,
|
||||
.footprint-map-header p { margin: 0; }
|
||||
.footprint-map-title { font-size: 1.15rem; }
|
||||
.footprint-map-header .footprint-map-legend { color: var(--fm-muted); font-size: .78rem; margin-top: 3px; }
|
||||
.footprint-map-header .footprint-map-legend,
|
||||
.footprint-map-header .footprint-map-zoom-hint { color: var(--fm-muted); font-size: .78rem; margin-top: 3px; }
|
||||
.footprint-map-header .footprint-map-zoom-hint { font-size: .72rem; }
|
||||
|
||||
.footprint-map-fit,
|
||||
.footprint-map-source-form button {
|
||||
|
|
|
|||
|
|
@ -10,11 +10,13 @@ describe("internationalization", () => {
|
|||
it("follows a Chinese Obsidian locale in automatic mode", () => {
|
||||
expect(resolveLocale("auto", "zh-cn")).toBe("zh-CN");
|
||||
expect(createI18n("auto", "zh-cn").t("fitAll")).toBe("适配全部点位");
|
||||
expect(createI18n("auto", "zh-cn").t("zoomHint")).toContain("⌘/Ctrl");
|
||||
});
|
||||
|
||||
it("allows an explicit language to override the host locale", () => {
|
||||
const i18n = createI18n("en", "zh-cn");
|
||||
expect(i18n.t("fitAll")).toBe("Fit all places");
|
||||
expect(i18n.t("zoomHint")).toContain("Pinch");
|
||||
expect(i18n.period("afternoon")).toBe("Afternoon");
|
||||
});
|
||||
|
||||
|
|
|
|||
19
tests/unit/map-options.test.ts
Normal file
19
tests/unit/map-options.test.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { createLeafletMapOptions } from "../../src/renderer/map-options";
|
||||
|
||||
describe("Leaflet map options", () => {
|
||||
it("keeps Leaflet wheel and touch handlers available behind the intent gate", () => {
|
||||
const options = createLeafletMapOptions(false);
|
||||
|
||||
expect(options.scrollWheelZoom).toBe(true);
|
||||
expect(options.touchZoom).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps static AMap zoom levels on whole-number steps", () => {
|
||||
const options = createLeafletMapOptions(true);
|
||||
|
||||
expect(options.zoomSnap).toBe(1);
|
||||
expect(options.minZoom).toBe(2);
|
||||
expect(options.maxZoom).toBe(18);
|
||||
});
|
||||
});
|
||||
23
tests/unit/wheel-zoom-gate.test.ts
Normal file
23
tests/unit/wheel-zoom-gate.test.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import { gateWheelZoom, isIntentionalWheelZoom } from "../../src/renderer/wheel-zoom-gate";
|
||||
|
||||
describe("wheel zoom intent gate", () => {
|
||||
it("leaves plain wheel events to the surrounding document", () => {
|
||||
const stopImmediatePropagation = vi.fn();
|
||||
|
||||
gateWheelZoom({ ctrlKey: false, metaKey: false, stopImmediatePropagation });
|
||||
|
||||
expect(stopImmediatePropagation).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("allows trackpad pinch and Ctrl/Command wheel events to reach Leaflet", () => {
|
||||
expect(isIntentionalWheelZoom({ ctrlKey: true, metaKey: false })).toBe(true);
|
||||
expect(isIntentionalWheelZoom({ ctrlKey: false, metaKey: true })).toBe(true);
|
||||
|
||||
const stopImmediatePropagation = vi.fn();
|
||||
gateWheelZoom({ ctrlKey: true, metaKey: false, stopImmediatePropagation });
|
||||
gateWheelZoom({ ctrlKey: false, metaKey: true, stopImmediatePropagation });
|
||||
|
||||
expect(stopImmediatePropagation).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
6
trd.md
6
trd.md
|
|
@ -1,12 +1,12 @@
|
|||
# TRD:Footprint Map
|
||||
|
||||
**职责:** 将 REQ-004 v0.22 转化为可实施的技术基线,定义技术选型、代码层级、核心接口、数据格式、渲染流程、错误模型、测试范围与发布产物。
|
||||
**职责:** 将 REQ-004 v0.23 转化为可实施的技术基线,定义技术选型、代码层级、核心接口、数据格式、渲染流程、错误模型、测试范围与发布产物。
|
||||
|
||||
## 文档状态
|
||||
|
||||
- 状态:技术设计草案,待实施前审阅。
|
||||
- TRD 版本:v0.1。
|
||||
- 需求基线:[[../../Requirements/REQ-004-可移植Markdown互动足迹地图|REQ-004 v0.22]]。
|
||||
- 需求基线:[[../../Requirements/REQ-004-可移植Markdown互动足迹地图|REQ-004 v0.23]]。
|
||||
- 项目设计:[[design|设计图]]、[[interaction|交互图]]、[[visual|效果图]]。
|
||||
- 实施状态:MVP 源码、自动化测试、Obsidian 包与 standalone 产物已创建;待真实宿主和照片样本验收。
|
||||
|
||||
|
|
@ -54,7 +54,7 @@
|
|||
- 静态图作为 Leaflet `ImageOverlay` 的底层 pane;照片 Marker、地图外横向照片浏览区、虚线 Polyline、方向箭头与视野适配复用同一 Leaflet 控制器。
|
||||
- 静态图仅在初始布局完成以及 `moveend` / `zoomend` 后防抖刷新,使用 `scale=2` 与受限的图片尺寸,不预取、不批量缓存。
|
||||
- 请求失败时移除无效底图并显示警告,本地点位、连线、箭头、时间轴和照片仍可使用。
|
||||
- 作为 Markdown 内嵌组件,Leaflet 设置 `scrollWheelZoom: false`,让桌面滚动事件继续服务于文档浏览,同时保留主动地图交互。
|
||||
- 作为 Markdown 内嵌组件,Leaflet 滚轮处理器前设事件门控:无修饰键滚动保留给文档,只允许触控板捏合或按住 `Command`/`Ctrl` 后滚动缩放地图;移动端触摸缩放不受影响。
|
||||
|
||||
### ADR-003:`exifr` 作为 EXIF 解析实现
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue