diff --git a/AGENTS.md b/AGENTS.md --- a/AGENTS.md +++ b/AGENTS.md @@ -162,3 +162,4 @@ 6. **Mark issues done** via `chainlink close ` when complete 7. **Use `chainlink start `** to track time on issues 8. **The spec in the issue is the source of truth** — if the code contradicts the issue description, the code is wrong +9. **Fix adversary findings immediately** — don't defer non-blocking issues to later phases. Fix them while you have context. Less work later, fewer things to remember, and the codebase stays clean as you go. diff --git a/CHANGELOG.md b/CHANGELOG.md --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,23 @@ ### Fixed ### Changed +- styles.css (#7) +- Adversarial review: main.ts (#27) +- main.ts — TDD (#6) +- Refactor main.ts (#22) +- Implement main.ts (#21) +- Write main.ts tests (#20) +- Adversarial review: mapView.ts (#26) +- mapView.ts — TDD (#5) +- Refactor mapView (#19) +- Implement mapView (#18) +- Write mapView tests (#17) +- Adversarial review: mapRenderer.ts (#25) +- mapRenderer.ts — TDD (#4) +- Refactor mapRenderer (#16) +- Implement mapRenderer (#15) +- Write mapRenderer tests (#14) +- Adversarial review: geocoder.ts (#24) - geocoder.ts — TDD (#3) - Refactor geocoder (#13) - Implement geocoder (#12) diff --git a/esbuild.config.mjs b/esbuild.config.mjs --- a/esbuild.config.mjs +++ b/esbuild.config.mjs @@ -5,7 +5,7 @@ const prod = process.argv[2] === "production"; const context = await esbuild.context({ - entryPoints: ["main.ts"], + entryPoints: ["src/main.ts"], bundle: true, external: [ "obsidian", @@ -28,6 +28,7 @@ logLevel: "info", sourcemap: prod ? false : "inline", treeShaking: true, + minify: prod, outfile: "main.js", }); diff --git a/package-lock.json b/package-lock.json --- a/package-lock.json +++ b/package-lock.json @@ -18,11 +18,19 @@ "builtin-modules": "^3.3.0", "esbuild": "^0.20.0", "fast-check": "^3.0.0", + "jsdom": "^28.1.0", "obsidian": "latest", "tslib": "^2.6.0", "typescript": "^5.3.0", "vitest": "^3.0.0" } + }, + "node_modules/@acemir/cssom": { + "version": "0.9.31", + "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz", + "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==", + "dev": true, + "license": "MIT" }, "node_modules/@ampproject/remapping": { "version": "2.3.0", @@ -37,6 +45,64 @@ "engines": { "node": ">=6.0.0" } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.0.1.tgz", + "integrity": "sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.1.1", + "@csstools/css-color-parser": "^4.0.2", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.2.6" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "6.8.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz", + "integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.1.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.6" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" }, "node_modules/@babel/helper-string-parser": { "version": "7.27.1", @@ -98,6 +164,19 @@ "node": ">=18" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, "node_modules/@codemirror/state": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.0.tgz", @@ -121,6 +200,138 @@ "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz", + "integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz", + "integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.1.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.0.tgz", + "integrity": "sha512-H4tuz2nhWgNKLt1inYpoVCfbJbMwX/lQKp3g69rrrIMIYlFD9+zTykOKhNR8uGrAmbS/kT9n6hTFkmDkxLgeTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0" + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" } }, "node_modules/@esbuild/aix-ppc64": { @@ -563,6 +774,24 @@ ], "engines": { "node": ">=12" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", + "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } } }, "node_modules/@isaacs/cliui": { @@ -1222,6 +1451,16 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ansi-regex": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", @@ -1278,6 +1517,16 @@ "license": "MIT", "engines": { "node": "18 || 20 || >=22" + } + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" } }, "node_modules/brace-expansion": { @@ -1386,6 +1635,60 @@ "node": ">= 8" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/cssstyle": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-6.2.0.tgz", + "integrity": "sha512-Fm5NvhYathRnXNVndkUsCCuR63DCLVVwGOOwQw782coXFi5HhkXdu289l59HlXZBawsyNccXfWRYvLzcDCdDig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.0.1", + "@csstools/css-syntax-patches-for-csstree": "^1.0.28", + "css-tree": "^3.1.0", + "lru-cache": "^11.2.6" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cssstyle/node_modules/lru-cache": { + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1403,6 +1706,13 @@ "optional": true } } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" }, "node_modules/deep-eql": { "version": "5.0.2", @@ -1427,6 +1737,19 @@ "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", "dev": true, "license": "MIT" + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } }, "node_modules/es-module-lexer": { "version": "1.7.0", @@ -1632,12 +1955,53 @@ "node": ">=8" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", "dev": true, "license": "MIT" + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", @@ -1648,6 +2012,13 @@ "engines": { "node": ">=8" } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" }, "node_modules/isexe": { "version": "2.0.0", @@ -1733,6 +2104,47 @@ "dev": true, "license": "MIT" }, + "node_modules/jsdom": { + "version": "28.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.1.0.tgz", + "integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@acemir/cssom": "^0.9.31", + "@asamuzakjp/dom-selector": "^6.8.1", + "@bramus/specificity": "^2.4.2", + "@exodus/bytes": "^1.11.0", + "cssstyle": "^6.0.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.0", + "undici": "^7.21.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/leaflet": { "version": "1.9.4", "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", @@ -1790,6 +2202,13 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" }, "node_modules/minimatch": { "version": "10.2.4", @@ -1874,6 +2293,19 @@ "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", "dev": true, "license": "BlueOak-1.0.0" + }, + "node_modules/parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } }, "node_modules/path-key": { "version": "3.1.1", @@ -1968,6 +2400,16 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/pure-rand": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", @@ -1984,6 +2426,16 @@ } ], "license": "MIT" + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } }, "node_modules/rollup": { "version": "4.59.0", @@ -2028,6 +2480,19 @@ "@rollup/rollup-win32-x64-gnu": "4.59.0", "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" + } + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" } }, "node_modules/semver": { @@ -2255,6 +2720,13 @@ "node": ">=8" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/test-exclude": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.2.tgz", @@ -2331,6 +2803,52 @@ "node": ">=14.0.0" } }, + "node_modules/tldts": { + "version": "7.0.25", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.25.tgz", + "integrity": "sha512-keinCnPbwXEUG3ilrWQZU+CqcTTzHq9m2HhoUP2l7Xmi8l1LuijAXLpAJ5zRW+ifKTNscs4NdCkfkDCBYm352w==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.25" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.25", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.25.tgz", + "integrity": "sha512-ZjCZK0rppSBu7rjHYDYsEaMOIbbT+nWF57hKkv4IUmZWBNrBWBOjIElc0mKRgLM8bm7x/BBlof6t2gi/Oq/Asw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", + "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -2350,6 +2868,16 @@ }, "engines": { "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "7.22.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.22.0.tgz", + "integrity": "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" } }, "node_modules/undici-types": { @@ -2971,6 +3499,54 @@ "license": "MIT", "peer": true }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -3101,6 +3677,23 @@ "engines": { "node": ">=8" } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" } } } diff --git a/package.json b/package.json --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "builtin-modules": "^3.3.0", "esbuild": "^0.20.0", "fast-check": "^3.0.0", + "jsdom": "^28.1.0", "obsidian": "latest", "tslib": "^2.6.0", "typescript": "^5.3.0", diff --git a/styles.css b/styles.css new file mode 100644 --- /dev/null +++ b/styles.css @@ -0,0 +1,127 @@ +/* Map Viewer - styles.css */ +/* All colors use Obsidian CSS variables for theme compatibility. */ +/* No fixed widths/heights — responsive to sidebar resize. */ +/* All Leaflet overrides scoped under .map-viewer-container to avoid */ +/* bleeding into other plugins that use Leaflet. */ + +/* ── Map container ── */ + +.map-viewer-container { + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + overflow: hidden; +} + +/* ── Map element ── */ + +.map-viewer-map { + flex: 1; + min-height: 0; + overflow: hidden; +} + +/* ── SVG teardrop pin markers ── */ + +.map-viewer-pin { + background: none !important; + border: none !important; + filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.35)); +} + +/* ── Marker highlight pulse ── */ + +@keyframes map-pulse { + 0% { + stroke-opacity: 0.8; + } + 100% { + stroke-opacity: 0.3; + } +} + +.map-marker-highlight { + animation: map-pulse 1.5s ease-in-out infinite alternate; +} + +/* ── Popup styles (Obsidian theme integration) ── */ +/* Scoped under .map-viewer-container so we don't affect other plugins. */ + +.map-viewer-container .leaflet-popup-content-wrapper { + background: var(--background-primary); + color: var(--text-normal); + border-radius: 6px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + font-family: var(--font-interface); + font-size: 12px; +} + +.map-viewer-container .leaflet-popup-tip { + background: var(--background-primary); + box-shadow: none; +} + +.map-viewer-container .leaflet-popup-content { + margin: 8px 12px; + line-height: 1.5; +} + +.map-viewer-container .leaflet-popup-content p { + margin: 4px 0; +} + +.map-viewer-container .leaflet-popup-content a { + color: var(--text-accent); + text-decoration: none; +} + +.map-viewer-container .leaflet-popup-content a:hover { + text-decoration: underline; +} + +.map-viewer-container .leaflet-popup-close-button { + color: var(--text-muted); +} + +.map-viewer-container .leaflet-popup-close-button:hover, +.map-viewer-container .leaflet-popup-close-button:focus { + color: var(--text-normal); +} + +/* ── Zoom control styles (theme-aware) ── */ + +.map-viewer-container .leaflet-control-zoom { + border: 2px solid var(--background-modifier-border); + border-radius: 4px; + margin-top: 6px; + margin-right: 6px; +} + +.map-viewer-container .leaflet-control-zoom a { + background-color: var(--background-primary); + border-bottom: 1px solid var(--background-modifier-border); + color: var(--text-normal); +} + +.map-viewer-container .leaflet-control-zoom a:hover { + background-color: var(--background-modifier-hover); +} + +/* ── Attribution styles ── */ + +.map-viewer-container .leaflet-control-attribution { + font-size: 9px; + background: var(--background-primary-alt) !important; + color: var(--text-faint); +} + +.map-viewer-container .leaflet-control-attribution a { + color: var(--text-muted); +} + +/* ── Leaflet container background for dark themes ── */ + +.map-viewer-container .leaflet-container { + background: var(--background-primary-alt); +} diff --git a/tsconfig.json b/tsconfig.json --- a/tsconfig.json +++ b/tsconfig.json @@ -4,15 +4,15 @@ "inlineSourceMap": true, "inlineSources": true, "module": "ESNext", - "target": "ES6", + "target": "ES2018", "allowJs": true, - "noImplicitAny": true, + "strict": true, + "skipLibCheck": true, "moduleResolution": "node", "importHelpers": true, "isolatedModules": true, - "strictNullChecks": true, - "lib": ["DOM", "ES5", "ES6", "ES7"] + "lib": ["DOM", "ES5", "ES6", "ES7", "ES2018"] }, - "include": ["**/*.ts"], + "include": ["src/**/*.ts"], "exclude": ["node_modules"] } diff --git a/vitest.config.ts b/vitest.config.ts --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,6 +1,12 @@ import { defineConfig } from "vitest/config"; +import path from "path"; export default defineConfig({ + resolve: { + alias: { + obsidian: path.resolve(__dirname, "tests/__mocks__/obsidian.ts"), + }, + }, test: { globals: true, environment: "node", diff --git a/.chainlink/issues.db b/.chainlink/issues.db --- a/.chainlink/issues.db +++ b/.chainlink/issues.db diff --git a/src/geocoder.ts b/src/geocoder.ts --- a/src/geocoder.ts +++ b/src/geocoder.ts @@ -14,6 +14,8 @@ export interface GeocodeCallbacks { onProgress?: (place: Place, result: GeoResult | null) => void; + /** Called when the user should be shown a notice (e.g., repeated geocoding failures). */ + onNotice?: (message: string) => void; } /** Minimum delay between sequential Nominatim requests (ms). */ @@ -31,11 +33,15 @@ /** * Geocode an array of places via Nominatim. * + * **Mutates** the input `places` array in-place — successfully geocoded places + * have their `lat` and `lng` properties set directly on the original objects. + * Returns the same array reference (not a copy). + * * - Only geocodes places where lat AND lng are both undefined/null. * - Deduplicates by case-insensitive trimmed name. * - Rate-limits to 1100ms between sequential requests. * - Supports external cancellation via AbortSignal. - * - Reports progress via onProgress callback. + * - Reports progress via onProgress callback (once per unique geocode, not per duplicate). */ export async function geocodePlaces( places: Place[], @@ -105,14 +111,9 @@ consecutiveFailures++; // Contract 12: Notice on exactly the 3rd consecutive failure (not every subsequent one) if (consecutiveFailures === CONSECUTIVE_FAILURE_NOTICE_THRESHOLD) { - try { - // eslint-disable-next-line no-undef - new (globalThis as any).Notice( - "Map Viewer: Geocoding issues — check your network connection" - ); - } catch { - // Notice may not exist outside Obsidian — swallow silently - } + callbacks?.onNotice?.( + "Map Viewer: Geocoding issues — check your network connection" + ); } } else { consecutiveFailures = 0; @@ -151,8 +152,11 @@ // Combine external signal and timeout signal const combinedController = new AbortController(); + // Named handlers for cleanup — pass reason for debuggability + const onExternalAbort = () => combinedController.abort("cancelled"); + const onTimeoutAbort = () => combinedController.abort("timeout"); + // If external signal aborts, abort combined - const onExternalAbort = () => combinedController.abort(); if (externalSignal) { if (externalSignal.aborted) { clearTimeout(timeoutId); @@ -162,9 +166,7 @@ } // If timeout aborts, abort combined - timeoutController.signal.addEventListener("abort", () => - combinedController.abort() - ); + timeoutController.signal.addEventListener("abort", onTimeoutAbort); // Contract 2: build Nominatim URL const url = `${NOMINATIM_BASE}?format=json&limit=1&q=${encodeURIComponent(name)}`; @@ -175,6 +177,11 @@ headers: { "User-Agent": USER_AGENT }, signal: combinedController.signal, }); + + if (!response.ok) { + console.warn("[MapViewer] HTTP error for:", name, response.status); + return null; + } const data = await response.json(); @@ -188,18 +195,28 @@ const lat = parseFloat(data[0].lat); const lng = parseFloat(data[0].lon); - if (isNaN(lat) || isNaN(lng)) { - console.warn("[MapViewer] Invalid coordinates for:", name); + if (isNaN(lat) || isNaN(lng) || lat < -90 || lat > 90 || lng < -180 || lng > 180) { + console.warn("[MapViewer] Invalid coordinates for:", name, { lat, lng }); return null; } return { lat, lng }; } catch (err) { // Contract 8 & 12: network failures logged, place skipped - console.warn("[MapViewer] Geocode failed for:", name, err); + if (err instanceof DOMException && err.name === "AbortError") { + const reason = combinedController.signal.reason; + if (reason === "timeout") { + console.warn("[MapViewer] Geocode timed out for:", name); + } else { + console.warn("[MapViewer] Geocode cancelled for:", name); + } + } else { + console.warn("[MapViewer] Geocode failed for:", name, err); + } return null; } finally { clearTimeout(timeoutId); + timeoutController.signal.removeEventListener("abort", onTimeoutAbort); if (externalSignal) { externalSignal.removeEventListener("abort", onExternalAbort); } @@ -213,10 +230,14 @@ resolve(); return; } - const timerId = setTimeout(resolve, ms); - signal?.addEventListener("abort", () => { + const onAbort = () => { clearTimeout(timerId); resolve(); - }, { once: true }); + }; + const timerId = setTimeout(() => { + signal?.removeEventListener("abort", onAbort); + resolve(); + }, ms); + signal?.addEventListener("abort", onAbort, { once: true }); }); } diff --git a/src/main.ts b/src/main.ts new file mode 100644 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,122 @@ +/** + * main.ts — Plugin Entry (Effectful/Obsidian API) + * + * Registers the map viewer plugin, its custom view, commands, + * ribbon icon, and workspace event handlers. + */ + +import { Plugin, MarkdownView } from "obsidian"; +import type { WorkspaceLeaf, TAbstractFile, TFile } from "obsidian"; +import { VIEW_TYPE, MapViewerView } from "./mapView"; + +export default class MapViewerPlugin extends Plugin { + private lastActiveFilePath: string | null = null; + + async onload(): Promise { + // Contract #1: Register view type with factory + this.registerView(VIEW_TYPE, (leaf: WorkspaceLeaf) => { + return new MapViewerView(leaf); + }); + + // Contract #2: Ribbon icon + this.addRibbonIcon("map-pin", "Open map view", () => { + this.activateView(); + }); + + // Contract #3: Command + this.addCommand({ + id: "open-map-view", + name: "Open map view", + callback: () => { + this.activateView(); + }, + }); + + // Contract #5: active-leaf-change event + // NOTE: mapView.ts also registers this event for view-specific logic (e.g., + // clearMap on non-MarkdownView). Both handlers may fire; the view's debounce + // in scheduleRefresh() collapses duplicate triggers. main.ts adds + // lastActiveFilePath dedup to avoid unnecessary refresh calls. + this.registerEvent( + this.app.workspace.on("active-leaf-change", (leaf: WorkspaceLeaf | null) => { + this.onActiveLeafChange(leaf); + }) + ); + + // Contract #6: vault modify event + // NOTE: mapView.ts also registers this event with its own write-guard check. + // Both handlers may fire; the view's debounce collapses duplicate triggers. + this.registerEvent( + this.app.vault.on("modify", (file: TAbstractFile) => { + this.onFileModify(file as TFile); + }) + ); + } + + onunload(): void { + // Contract #7: Obsidian handles view deregistration automatically. + // No special cleanup needed. + } + + /** + * Contract #4: Open the map view in the right sidebar as a singleton. + */ + private async activateView(): Promise { + const leaves = this.app.workspace.getLeavesOfType(VIEW_TYPE); + + if (leaves.length > 0) { + // Reveal existing leaf + this.app.workspace.revealLeaf(leaves[0]); + return; + } + + // Create new leaf in right sidebar + const leaf = this.app.workspace.getRightLeaf(false); + if (!leaf) return; + await leaf.setViewState({ + type: VIEW_TYPE, + active: true, + }); + this.app.workspace.revealLeaf(leaf); + } + + /** + * Contract #5: When active leaf changes, call the view's refresh() + * if the new leaf is a MarkdownView and the file has changed. + */ + private onActiveLeafChange(_leaf: WorkspaceLeaf | null): void { + const mdView = this.app.workspace.getActiveViewOfType(MarkdownView); + if (!mdView) return; + + // Dedup: skip if same file as last time + const filePath = mdView.file?.path ?? null; + if (filePath !== null && filePath === this.lastActiveFilePath) return; + this.lastActiveFilePath = filePath; + + this.refreshMapView(); + } + + /** + * Contract #6: When a file is modified, refresh the map view + * if the modified file is the currently active file. + */ + private onFileModify(file: TFile): void { + const mdView = this.app.workspace.getActiveViewOfType(MarkdownView); + if (!mdView?.file || mdView.file !== file) return; + + this.refreshMapView(); + } + + /** + * Find the map-viewer view and call its refresh() method. + */ + private refreshMapView(): void { + const leaves = this.app.workspace.getLeavesOfType(VIEW_TYPE); + if (leaves.length === 0) return; + + const view = leaves[0].view; + if (view && typeof (view as MapViewerView).refresh === "function") { + (view as MapViewerView).refresh(); + } + } +} diff --git a/src/mapRenderer.ts b/src/mapRenderer.ts new file mode 100644 --- /dev/null +++ b/src/mapRenderer.ts @@ -0,0 +1,569 @@ +/** + * mapRenderer.ts — Leaflet Map (Effectful/DOM) + * + * Create and manage a Leaflet map with place markers, selection highlighting, + * and popups. Uses Stadia Maps Watercolor + CartoDB Light Labels tiles. + */ + +import * as L from "leaflet"; +import type { Place } from "./parser"; + +// ─── Types ──────────────────────────────────────────────────────────── + +export interface MapCallbacks { + onPlaceSelect?: (places: Place[]) => void; +} + +export interface MapController { + updateMarkers(places: Place[], fitBounds?: boolean): void; + selectPlace(place: Place | null): void; + fitBounds(): void; + invalidateSize(): void; + destroy(): void; +} + +// ─── Constants ──────────────────────────────────────────────────────── + +// Stamen Watercolor tiles hosted by the Smithsonian / Cooper Hewitt (free, no API key) +const WATERCOLOR_URL = + "https://watercolormaps.collection.cooperhewitt.org/tile/watercolor/{z}/{x}/{y}.jpg"; +// CartoDB light labels overlay (free, no API key) +const LABELS_URL = + "https://{s}.basemaps.cartocdn.com/light_only_labels/{z}/{x}/{y}{r}.png"; + +const DEFAULT_CENTER: L.LatLngExpression = [20, 0]; +const DEFAULT_ZOOM = 2; +const SINGLE_MARKER_ZOOM = 13; + +// ─── Leaflet CSS Injection ──────────────────────────────────────────── + +/** Leaflet CSS as a minimal inline string — injected into document.head once. */ +const LEAFLET_CSS_ID = "leaflet-css"; + +function injectLeafletCSS(): void { + if (document.getElementById(LEAFLET_CSS_ID)) return; + + const style = document.createElement("style"); + style.id = LEAFLET_CSS_ID; + // Structural Leaflet CSS only — positioning, z-index, overflow, cursors. + // All cosmetic/visual styles (colors, backgrounds, shadows, animations) + // are owned by styles.css for Obsidian theme compatibility. + style.textContent = ` + .leaflet-pane, + .leaflet-tile, + .leaflet-marker-icon, + .leaflet-marker-shadow, + .leaflet-tile-container, + .leaflet-pane > svg, + .leaflet-pane > canvas, + .leaflet-zoom-box, + .leaflet-image-layer, + .leaflet-layer { position: absolute; left: 0; top: 0; } + .leaflet-container { overflow: hidden; -webkit-tap-highlight-color: transparent; } + .leaflet-tile, .leaflet-marker-icon, .leaflet-marker-shadow { user-select: none; -webkit-user-select: none; } + .leaflet-tile::selection { background: transparent; } + .leaflet-safari .leaflet-tile { image-rendering: -webkit-optimize-contrast; } + .leaflet-tile { filter: inherit; visibility: hidden; } + .leaflet-tile-loaded { visibility: inherit; } + .leaflet-zoom-box { width: 0; height: 0; box-sizing: border-box; z-index: 800; } + .leaflet-overlay-pane svg { -moz-user-select: none; } + .leaflet-pane { z-index: 400; } + .leaflet-tile-pane { z-index: 200; } + .leaflet-overlay-pane { z-index: 400; } + .leaflet-shadow-pane { z-index: 500; } + .leaflet-marker-pane { z-index: 600; } + .leaflet-tooltip-pane { z-index: 650; } + .leaflet-popup-pane { z-index: 700; } + .leaflet-map-pane canvas { z-index: 100; } + .leaflet-map-pane svg { z-index: 200; } + .leaflet-control { position: relative; z-index: 800; pointer-events: visiblePainted; pointer-events: auto; } + .leaflet-top, .leaflet-bottom { position: absolute; z-index: 1000; pointer-events: none; } + .leaflet-top { top: 0; } + .leaflet-right { right: 0; } + .leaflet-bottom { bottom: 0; } + .leaflet-left { left: 0; } + .leaflet-popup-content-wrapper { padding: 1px; text-align: left; } + .leaflet-popup-tip-container { width: 40px; height: 20px; position: absolute; left: 50%; margin-left: -20px; overflow: hidden; pointer-events: none; } + .leaflet-popup-tip { width: 17px; height: 17px; padding: 1px; margin: -10px auto 0; transform: rotate(45deg); } + .leaflet-popup-close-button { position: absolute; top: 0; right: 0; border: none; text-align: center; width: 24px; height: 24px; font: 16px/24px Tahoma, Verdana, sans-serif; text-decoration: none; background: transparent; cursor: pointer; } + .leaflet-popup { position: absolute; text-align: center; margin-bottom: 20px; } + .leaflet-control-zoom a { width: 30px; height: 30px; line-height: 30px; display: block; text-align: center; text-decoration: none; font: bold 18px 'Lucida Console', Monaco, monospace; cursor: pointer; } + .leaflet-control-zoom-in { border-top-left-radius: 2px; border-top-right-radius: 2px; } + .leaflet-control-zoom-out { border-bottom-left-radius: 2px; border-bottom-right-radius: 2px; } + .leaflet-control-attribution { padding: 0 5px; } + .leaflet-control-attribution a { text-decoration: none; } + .leaflet-grab { cursor: grab; } + .leaflet-dragging .leaflet-grab { cursor: grabbing; } + .leaflet-fade-anim .leaflet-popup { opacity: 1; transition: opacity 0.2s linear; } + .leaflet-zoom-anim .leaflet-zoom-animated { transition: transform 0.25s cubic-bezier(0,0,0.25,1); } + `; + document.head.appendChild(style); +} + +// ─── Color Utilities ────────────────────────────────────────────────── + +/** + * Parse any CSS color string to RGB components using a canvas 2d context. + * Handles hex, rgb(), rgba(), hsl(), hsla(), oklch(), named colors, etc. + * Falls back to regex parsing if canvas is unavailable (e.g., in tests). + * Returns null if the color cannot be parsed. + */ +function parseColorToRgb(color: string): { r: number; g: number; b: number } | null { + const trimmed = color.trim(); + if (!trimmed) return null; + + // Try canvas-based parsing first (handles all CSS color formats) + try { + const ctx = document.createElement("canvas").getContext("2d"); + if (ctx) { + ctx.fillStyle = "#000000"; // reset to known value + ctx.fillStyle = trimmed; + // If the browser didn't recognize the color, fillStyle stays "#000000" + const result = ctx.fillStyle; + if (result === "#000000" && trimmed.toLowerCase() !== "#000000" && trimmed.toLowerCase() !== "black") { + // Color was not recognized — fall through to regex + } else { + // Parse the result (always #rrggbb or an rgb()/rgba() string) + const hexMatch = result.match(/^#([0-9a-f]{6})$/i); + if (hexMatch) { + return { + r: parseInt(hexMatch[1].substring(0, 2), 16), + g: parseInt(hexMatch[1].substring(2, 4), 16), + b: parseInt(hexMatch[1].substring(4, 6), 16), + }; + } + const rgbMatch = result.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+)/); + if (rgbMatch) { + return { + r: parseInt(rgbMatch[1], 10), + g: parseInt(rgbMatch[2], 10), + b: parseInt(rgbMatch[3], 10), + }; + } + } + } + } catch { + // Canvas unavailable — fall through to regex + } + + // Regex fallback for environments without canvas (tests, SSR) + const hex6Match = trimmed.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i); + if (hex6Match) { + return { + r: parseInt(hex6Match[1], 16), + g: parseInt(hex6Match[2], 16), + b: parseInt(hex6Match[3], 16), + }; + } + + const hex3Match = trimmed.match(/^#([0-9a-f])([0-9a-f])([0-9a-f])$/i); + if (hex3Match) { + return { + r: parseInt(hex3Match[1] + hex3Match[1], 16), + g: parseInt(hex3Match[2] + hex3Match[2], 16), + b: parseInt(hex3Match[3] + hex3Match[3], 16), + }; + } + + const rgbMatch = trimmed.match(/^rgb\(\s*(\d{1,3})\s*[,\s]\s*(\d{1,3})\s*[,\s]\s*(\d{1,3})\s*\)$/i); + if (rgbMatch) { + const r = parseInt(rgbMatch[1], 10); + const g = parseInt(rgbMatch[2], 10); + const b = parseInt(rgbMatch[3], 10); + if (r <= 255 && g <= 255 && b <= 255) { + return { r, g, b }; + } + } + + return null; +} + +/** + * Convert RGB to hex string. + */ +function rgbToHex(r: number, g: number, b: number): string { + const toHex = (n: number) => + Math.max(0, Math.min(255, Math.round(n))) + .toString(16) + .padStart(2, "0"); + return `#${toHex(r)}${toHex(g)}${toHex(b)}`; +} + +const FALLBACK_ACCENT = "#7b6cd9"; + +/** + * Darken a CSS color by a percentage (0-100). + * Falls back to darkening the default accent if the color can't be parsed. + */ +function darkenColor(color: string, percent: number): string { + const rgb = parseColorToRgb(color); + if (!rgb) { + // Can't parse — darken the fallback instead + const fallback = parseColorToRgb(FALLBACK_ACCENT)!; + const factor = 1 - percent / 100; + return rgbToHex(fallback.r * factor, fallback.g * factor, fallback.b * factor); + } + const factor = 1 - percent / 100; + return rgbToHex(rgb.r * factor, rgb.g * factor, rgb.b * factor); +} + +/** + * Get the accent color from Obsidian CSS variables, with a sensible fallback. + * Always returns a value that can be used in SVG fill/stroke attributes. + */ +function getAccentColor(): string { + try { + const style = getComputedStyle(document.body); + const accent = style.getPropertyValue("--interactive-accent").trim(); + if (!accent) return FALLBACK_ACCENT; + // Validate that we can parse it — if not, return fallback hex + const rgb = parseColorToRgb(accent); + if (!rgb) return FALLBACK_ACCENT; + return rgbToHex(rgb.r, rgb.g, rgb.b); + } catch { + return FALLBACK_ACCENT; + } +} + +// ─── SVG Pin Icon ───────────────────────────────────────────────────── + +/** Validate that a string is a safe hex color for SVG attributes. */ +function isHexColor(s: string): boolean { + return /^#[0-9a-f]{6}$/i.test(s); +} + +/** + * Create an SVG teardrop pin icon for map markers. + */ +function createPinIcon(fillColor: string, strokeColor: string): L.DivIcon { + // Ensure colors are safe hex values to prevent SVG injection + const safeFill = isHexColor(fillColor) ? fillColor : FALLBACK_ACCENT; + const safeStroke = isHexColor(strokeColor) ? strokeColor : FALLBACK_ACCENT; + + const svg = ` + + + `; + + return L.divIcon({ + html: svg, + className: "map-viewer-pin", + iconSize: [24, 36] as L.PointExpression, + iconAnchor: [12, 36] as L.PointExpression, + popupAnchor: [0, -36] as L.PointExpression, + }); +} + +// ─── Marker Grouping ────────────────────────────────────────────────── + +interface MarkerGroup { + key: string; + lat: number; + lng: number; + places: Place[]; +} + +/** + * Group places by identical coordinates using toFixed(6) for comparison. + * Only includes places with both lat and lng defined. + */ +function groupPlacesByLocation(places: Place[]): MarkerGroup[] { + const groups = new Map(); + + for (const place of places) { + if (place.lat == null || place.lng == null) continue; + + const key = `${place.lat.toFixed(6)},${place.lng.toFixed(6)}`; + + if (groups.has(key)) { + groups.get(key)!.places.push(place); + } else { + groups.set(key, { + key, + lat: place.lat, + lng: place.lng, + places: [place], + }); + } + } + + return Array.from(groups.values()); +} + +// ─── Popup Content ──────────────────────────────────────────────────── + +/** + * Build popup HTML for a marker group. + * Each place is shown as a linked name (if url exists) or plain text. + */ +function buildPopupContent(places: Place[]): string { + const items = places.map((p) => { + if (p.url && isSafeUrl(p.url)) { + return `${escapeHtml(p.name)}`; + } + return escapeHtml(p.name); + }); + + return items.join("
"); +} + +/** Check if a URL is safe for use in href (reject javascript:, data:, vbscript:, etc.) */ +function isSafeUrl(url: string): boolean { + const trimmed = url.trim().toLowerCase(); + // Only allow http and https + if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) { + return true; + } + // Reject protocol-relative URLs (//evil.com) and any explicit protocol + if (trimmed.startsWith("//") || /^[a-z][a-z0-9+.-]*:/i.test(trimmed)) { + return false; + } + // Allow relative URLs (no protocol) + return true; +} + +function escapeHtml(text: string): string { + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +/** + * Escape a string for safe use in a double-quoted HTML attribute value. + * Only safe for `attr="..."` contexts — not single-quoted or unquoted attributes. + */ +function escapeAttr(text: string): string { + return escapeHtml(text); +} + +// ─── createMap ──────────────────────────────────────────────────────── + +export function createMap( + container: HTMLElement, + places: Place[], + callbacks: MapCallbacks +): MapController { + // Contract 1: Inject Leaflet CSS (idempotent) + injectLeafletCSS(); + + // Contract 2: Create map div inside container + const mapDiv = document.createElement("div"); + mapDiv.className = "map-viewer-map"; + container.appendChild(mapDiv); + + // Initialize Leaflet map + const map = L.map(mapDiv, { + zoomControl: true, + attributionControl: true, + }); + + // Contract 3: Add tile layers + const TILE_ATTRIBUTION = + 'Map tiles by Stamen Design, ' + + 'hosted by Cooper Hewitt. ' + + 'Labels by CARTO. ' + + 'Data © OpenStreetMap'; + + L.tileLayer(WATERCOLOR_URL, { + maxZoom: 18, + attribution: TILE_ATTRIBUTION, + }).addTo(map); + + L.tileLayer(LABELS_URL, { + maxZoom: 18, + attribution: TILE_ATTRIBUTION, + subdomains: "abcd", + }).addTo(map); + + // Internal state + let currentMarkers: L.Marker[] = []; + let currentGroups: MarkerGroup[] = []; + let highlightRing: L.CircleMarker | null = null; + let destroyed = false; + + // Map from startLine to MarkerGroup for selectPlace lookup + let startLineToGroup = new Map(); + // Map from group key to L.Marker + let groupKeyToMarker = new Map(); + + // ─── Marker Management ────────────────────────────────────────── + + function clearMarkers(): void { + // Remove highlight ring + removeHighlight(); + + // Remove all markers + for (const marker of currentMarkers) { + marker.remove(); + } + currentMarkers = []; + currentGroups = []; + startLineToGroup = new Map(); + groupKeyToMarker = new Map(); + } + + function createMarkers(newPlaces: Place[]): void { + const accentColor = getAccentColor(); + const strokeColor = darkenColor(accentColor, 25); + const icon = createPinIcon(accentColor, strokeColor); + + const groups = groupPlacesByLocation(newPlaces); + currentGroups = groups; + + // Build lookup maps + for (const group of groups) { + for (const place of group.places) { + startLineToGroup.set(place.startLine, group); + } + } + + // Create markers + for (const group of groups) { + const marker = L.marker([group.lat, group.lng], { icon }).addTo(map); + + // Contract 6: Popup + const popupContent = buildPopupContent(group.places); + marker.bindPopup(popupContent); + + // Contract 6: Click handler + marker.on("click", (e: unknown) => { + L.DomEvent.stopPropagation(e as L.LeafletEvent); + callbacks.onPlaceSelect?.(group.places); + }); + + currentMarkers.push(marker); + groupKeyToMarker.set(group.key, marker); + } + } + + function removeHighlight(): void { + if (highlightRing) { + highlightRing.remove(); + highlightRing = null; + } + } + + function applyFitBounds(): void { + if (currentGroups.length === 0) { + // Zero markers: show default world view + return; // View already set or preserve current + } + + if (currentGroups.length === 1) { + // Single marker: center at zoom 13 + const group = currentGroups[0]; + map.setView({ lat: group.lat, lng: group.lng }, SINGLE_MARKER_ZOOM); + return; + } + + // Multiple markers: fit bounds with padding + const bounds = L.latLngBounds( + currentGroups.map((g) => [g.lat, g.lng] as L.LatLngExpression) + ); + map.fitBounds(bounds, { padding: [50, 50] }); + } + + // ─── Initial Setup ────────────────────────────────────────────── + + // Create initial markers from provided places + createMarkers(places); + + // Set initial view based on marker count + if (currentGroups.length === 0) { + map.setView(DEFAULT_CENTER, DEFAULT_ZOOM); + } else { + applyFitBounds(); + } + + // Contract 13: ResizeObserver + const resizeObserver = new ResizeObserver(() => { + if (!destroyed) { + map.invalidateSize(); + } + }); + resizeObserver.observe(container); + + // ─── Controller ───────────────────────────────────────────────── + + const controller: MapController = { + updateMarkers(newPlaces: Place[], fitBoundsArg?: boolean): void { + if (destroyed) return; + + const shouldFit = fitBoundsArg !== false; // default true + + clearMarkers(); + createMarkers(newPlaces); + + if (shouldFit) { + applyFitBounds(); + } + }, + + selectPlace(place: Place | null): void { + if (destroyed) return; + + // Remove existing highlight + removeHighlight(); + + if (place === null) return; + + // Find the group for this place's startLine + const group = startLineToGroup.get(place.startLine); + if (!group) return; + + const marker = groupKeyToMarker.get(group.key); + if (!marker) return; + + // Pan/zoom to marker — never zoom out, only zoom in if needed + const targetZoom = Math.max(map.getZoom(), SINGLE_MARKER_ZOOM); + map.setView({ lat: group.lat, lng: group.lng }, targetZoom); + + // Show highlight ring + highlightRing = L.circleMarker([group.lat, group.lng], { + radius: 20, + color: getAccentColor(), + weight: 3, + fillOpacity: 0, + className: "map-marker-highlight", + }).addTo(map); + + // Open popup + marker.openPopup(); + }, + + fitBounds(): void { + if (destroyed) return; + applyFitBounds(); + }, + + invalidateSize(): void { + if (destroyed) return; + map.invalidateSize(); + }, + + destroy(): void { + if (destroyed) return; + destroyed = true; + + // Disconnect ResizeObserver + resizeObserver.disconnect(); + + // Clear all markers, highlight, and lookup maps + clearMarkers(); + + // Remove map + map.remove(); + + // Remove map div from container + if (mapDiv.parentNode) { + mapDiv.parentNode.removeChild(mapDiv); + } + }, + }; + + return controller; +} diff --git a/src/mapView.ts b/src/mapView.ts new file mode 100644 --- /dev/null +++ b/src/mapView.ts @@ -0,0 +1,401 @@ +/** + * mapView.ts — Sidebar View (Effectful/Obsidian API) + * + * Obsidian ItemView subclass that reads the active note, parses places, + * manages geocoding, writes geo data back to the note, and synchronizes + * cursor position with map markers. + */ + +import { ItemView, MarkdownView, Notice } from "obsidian"; +import type { WorkspaceLeaf, TAbstractFile, TFile } from "obsidian"; +import { parsePlaces, GEO_LINE_RE } from "./parser"; +import type { Place } from "./parser"; +import { geocodePlaces } from "./geocoder"; +import { createMap } from "./mapRenderer"; +import type { MapController } from "./mapRenderer"; + +export const VIEW_TYPE = "map-viewer"; + +const DEBOUNCE_MS = 300; +// Grace period after vault.process() completes before re-enabling modify +// event handling. Obsidian fires modify events asynchronously after writes; +// this window suppresses self-triggered refreshes. Trade-off: legitimate +// external edits within this window are also suppressed. +const WRITE_GUARD_MS = 500; +// Obsidian has no first-class cursor-move event. CodeMirror's cursorActivity +// extension requires @codemirror/view (external). Polling is the standard +// approach for Obsidian plugins; 200ms balances responsiveness vs cost. +const CURSOR_POLL_MS = 200; + +function computeFingerprint(places: Place[]): string { + return places + .map((p) => `${p.name}::${p.lat}::${p.lng}::${p.url ?? ""}::${p.startLine}`) + .join("|"); +} + +export class MapViewerView extends ItemView { + private mapController: MapController | null = null; + private currentPlaces: Place[] = []; + private lastFingerprint: string | null = null; + private writeGuardCounter = 0; + private currentAbortController: AbortController | null = null; + private debounceTimer: ReturnType | null = null; + private destroyed = false; + private lastSelectedStartLine: number | null = null; + private inFlightRefresh: Promise | null = null; + + constructor(leaf: WorkspaceLeaf) { + super(leaf); + } + + getViewType(): string { + return VIEW_TYPE; + } + + getDisplayText(): string { + return "Map"; + } + + getIcon(): string { + return "map-pin"; + } + + async onOpen(): Promise { + const container = document.createElement("div"); + container.className = "map-viewer-container"; + this.contentEl.appendChild(container); + + this.mapController = createMap(container, [], { + onPlaceSelect: (places: Place[]) => this.handlePlaceSelect(places), + }); + + this.registerEvents(); + this.startCursorSync(); + this.scheduleRefresh(); + } + + async onClose(): Promise { + this.destroyed = true; + + if (this.debounceTimer !== null) { + clearTimeout(this.debounceTimer); + this.debounceTimer = null; + } + + if (this.currentAbortController) { + this.currentAbortController.abort(); + this.currentAbortController = null; + } + + if (this.mapController) { + this.mapController.destroy(); + this.mapController = null; + } + } + + // NOTE: main.ts also registers active-leaf-change and modify handlers for + // plugin-level concerns (lastActiveFilePath dedup, active file check). Both + // sets of handlers may fire; scheduleRefresh() debounce collapses duplicates. + // The view's handlers add view-specific logic: clearMap on non-MarkdownView + // leaf change, and write-guard checks on modify. + private registerEvents(): void { + this.registerEvent( + this.app.vault.on("modify", (file: TAbstractFile) => this.onFileModify(file as TFile)) + ); + + this.registerEvent( + this.app.workspace.on( + "active-leaf-change", + (leaf) => this.onActiveLeafChange(leaf) + ) + ); + } + + private onFileModify(file: TFile): void { + if (this.writeGuardCounter > 0) return; + + // Only refresh if the modified file is the currently active file + const mdView = this.app.workspace.getActiveViewOfType(MarkdownView); + if (!mdView?.file || mdView.file !== file) return; + + this.scheduleRefresh(); + } + + private onActiveLeafChange(_leaf: WorkspaceLeaf | null): void { + const mdView = this.app.workspace.getActiveViewOfType(MarkdownView); + if (!mdView) { + this.clearMap(); + return; + } + this.scheduleRefresh(); + } + + /** + * Public entry point for external callers (e.g., main.ts event handlers). + * Delegates to the debounced scheduleRefresh(). + */ + refresh(): void { + this.scheduleRefresh(); + } + + private scheduleRefresh(): void { + if (this.debounceTimer !== null) { + clearTimeout(this.debounceTimer); + } + + this.debounceTimer = setTimeout(() => { + this.debounceTimer = null; + const promise = this.doRefresh().catch((err) => { + if (!this.destroyed) { + console.warn("[MapViewer] Refresh failed:", err); + } + }); + this.inFlightRefresh = promise; + promise.finally(() => { + if (this.inFlightRefresh === promise) { + this.inFlightRefresh = null; + } + }); + }, DEBOUNCE_MS); + } + + private async doRefresh(): Promise { + // Abort any in-flight operation from a previous refresh + if (this.currentAbortController) { + this.currentAbortController.abort(); + this.currentAbortController = null; + } + + // Create an abort controller for the entire refresh lifecycle + const abortController = new AbortController(); + this.currentAbortController = abortController; + + if (this.destroyed) return; + + const mdView = this.app.workspace.getActiveViewOfType(MarkdownView); + if (!mdView || !mdView.file) { + this.clearMap(); + return; + } + + const file = mdView.file; + const content = await this.app.vault.cachedRead(file); + + // Check for cancellation after async boundary + if (abortController.signal.aborted || this.destroyed) return; + + const places = parsePlaces(content); + const fingerprint = computeFingerprint(places); + + // Always store fresh places (fresh line ranges for cursor sync) + this.currentPlaces = places; + // Reset cursor sync state — line numbers may have shifted after re-parse + this.lastSelectedStartLine = null; + + if (this.lastFingerprint === null || fingerprint !== this.lastFingerprint) { + this.lastFingerprint = fingerprint; + if (this.mapController) { + this.mapController.updateMarkers(places); + } + } + + // Only geocode places that don't already have coordinates + const placesToGeocode = places.filter( + (p) => p.lat == null || p.lng == null + ); + + if (placesToGeocode.length > 0) { + await this.geocodeAndWriteBack( + places, + placesToGeocode, + file, + abortController + ); + } + } + + private async geocodeAndWriteBack( + allPlaces: Place[], + placesToGeocode: Place[], + file: TFile, + abortController: AbortController + ): Promise { + try { + // Pass only the places that need geocoding. The geocoder mutates in-place. + await geocodePlaces(placesToGeocode, { + onNotice: (msg) => new Notice(msg), + }, abortController.signal); + + if (abortController.signal.aborted || this.destroyed) return; + + // Check only the places that were ATTEMPTED for geocoding + const successfullyGeocoded = placesToGeocode.filter( + (p) => p.lat != null && p.lng != null + ); + + if (successfullyGeocoded.length === 0) { + new Notice("Map Viewer: No places could be geocoded"); + console.warn("[MapViewer] Geocoding produced zero results"); + return; + } + + // geocodePlaces() mutates in-place. placesToGeocode holds the same object + // references as allPlaces (via Array.filter), so allPlaces already has the + // geocoded coordinates. No explicit copy-back needed. + + // Only write back newly geocoded places, not pre-existing ones + this.writeGuardCounter++; + + try { + await this.app.vault.process(file, (currentContent: string) => { + return this.applyGeoWriteBack(currentContent, successfullyGeocoded); + }); + } finally { + setTimeout(() => { + this.writeGuardCounter--; + }, WRITE_GUARD_MS); + } + + if (abortController.signal.aborted || this.destroyed) return; + + // Update map with new coordinates (allPlaces now has geocoded coords) + if (this.mapController) { + const newFingerprint = computeFingerprint(allPlaces); + if (newFingerprint !== this.lastFingerprint) { + this.lastFingerprint = newFingerprint; + this.mapController.updateMarkers(allPlaces); + } + } + } catch (err) { + if (!abortController.signal.aborted && !this.destroyed) { + console.warn("[MapViewer] Geocoding failed:", err); + } + } + } + + private applyGeoWriteBack( + currentContent: string, + geocodedPlaces: Place[] + ): string { + const currentPlaces = parsePlaces(currentContent); + const lines = currentContent.split("\n"); + + // Build lookup: normalized name -> geocoded Place (first occurrence wins for coords) + const geocodedByName = new Map(); + for (const p of geocodedPlaces) { + if (p.lat != null && p.lng != null) { + const key = p.name.trim().toLowerCase(); + if (!geocodedByName.has(key)) { + geocodedByName.set(key, p); + } + } + } + + const operations: Array<{ + type: "insert" | "replace"; + lineIndex: number; + content: string; + }> = []; + + for (const currentPlace of currentPlaces) { + const normalizedName = currentPlace.name.trim().toLowerCase(); + const geocoded = geocodedByName.get(normalizedName); + if (!geocoded || geocoded.lat == null || geocoded.lng == null) continue; + + const geoLine = `\t* geo: ${geocoded.lat.toFixed(6)},${geocoded.lng.toFixed(6)}`; + + let existingGeoLine = -1; + for (let i = currentPlace.startLine + 1; i <= currentPlace.endLine; i++) { + if (GEO_LINE_RE.test(lines[i])) { + existingGeoLine = i; + break; + } + } + + if (existingGeoLine >= 0) { + operations.push({ + type: "replace", + lineIndex: existingGeoLine, + content: geoLine, + }); + } else { + operations.push({ + type: "insert", + lineIndex: currentPlace.endLine, + content: geoLine, + }); + } + } + + operations.sort((a, b) => b.lineIndex - a.lineIndex); + + for (const op of operations) { + if (op.type === "replace") { + lines[op.lineIndex] = op.content; + } else { + lines.splice(op.lineIndex + 1, 0, op.content); + } + } + + return lines.join("\n"); + } + + private startCursorSync(): void { + const intervalId = window.setInterval(() => { + this.pollCursorPosition(); + }, CURSOR_POLL_MS); + this.registerInterval(intervalId); + } + + private pollCursorPosition(): void { + if (!this.mapController || this.currentPlaces.length === 0) return; + + const mdView = this.app.workspace.getActiveViewOfType(MarkdownView); + if (!mdView || !mdView.editor) return; + + const cursor = mdView.editor.getCursor(); + const cursorLine = cursor.line; + + for (const place of this.currentPlaces) { + if (cursorLine >= place.startLine && cursorLine <= place.endLine) { + // Skip redundant selectPlace calls when cursor hasn't moved to a new place + if (this.lastSelectedStartLine === place.startLine) return; + this.lastSelectedStartLine = place.startLine; + this.mapController.selectPlace(place); + return; + } + } + + // Deselect — cursor is in a dead zone + if (this.lastSelectedStartLine !== null) { + this.lastSelectedStartLine = null; + this.mapController.selectPlace(null); + } + } + + private handlePlaceSelect(places: Place[]): void { + if (!places || places.length === 0) return; + + const mdView = this.app.workspace.getActiveViewOfType(MarkdownView); + if (!mdView || !mdView.editor) return; + + const firstPlace = places[0]; + mdView.editor.setCursor({ line: firstPlace.startLine, ch: 0 }); + mdView.editor.scrollIntoView( + { + from: { line: firstPlace.startLine, ch: 0 }, + to: { line: firstPlace.startLine, ch: 0 }, + }, + true + ); + } + + private clearMap(): void { + this.currentPlaces = []; + this.lastFingerprint = null; + this.lastSelectedStartLine = null; + if (this.mapController) { + this.mapController.updateMarkers([]); + } + } +} diff --git a/src/parser.ts b/src/parser.ts --- a/src/parser.ts +++ b/src/parser.ts @@ -26,14 +26,20 @@ */ const SUB_BULLET_RE = /^[\t ]{2,}[*-] |^\t[*-] /; -/** Regex for structured field: single word key, colon, space, then value */ -const FIELD_RE = /^(\w+): (.*)$/; +/** Regex for structured field: key (word chars + hyphens), colon, space, then value */ +const FIELD_RE = /^([\w-]+): (.*)$/; /** Regex for markdown link: [text](url) or [text](url "title") */ const MD_LINK_RE = /^\[([^\]]*)\]\(([^)"]*?)(?:\s+"[^"]*")?\)$/; /** Regex for wiki-link: [[Page]] or [[Target|Display]] */ const WIKI_LINK_RE = /^\[\[([^\]]*)\]\]$/; + +/** + * Regex matching a `geo:` sub-bullet line in raw note content. + * Shared between parser and mapView write-back logic. + */ +export const GEO_LINE_RE = /^[\t ]+[*-] geo: .*/; /** * Regex for valid geo coordinates. diff --git a/tests/geocoder.test.ts b/tests/geocoder.test.ts --- a/tests/geocoder.test.ts +++ b/tests/geocoder.test.ts @@ -47,7 +47,6 @@ beforeEach(() => { mockFetch = vi.fn(); vi.stubGlobal("fetch", mockFetch); - vi.stubGlobal("Notice", vi.fn()); vi.spyOn(console, "warn").mockImplementation(() => {}); }); @@ -144,6 +143,35 @@ expect(parsedUrl.searchParams.get("format")).toBe("json"); expect(parsedUrl.searchParams.get("limit")).toBe("1"); expect(parsedUrl.searchParams.get("q")).toBe("Sagrada Familia"); + }); + + it("properly encodes special characters and unicode in place names", async () => { + mockFetch + .mockResolvedValueOnce(nominatimOk(48.137, 11.575)) + .mockResolvedValueOnce(nominatimOk(48.856, 2.352)) + .mockResolvedValueOnce(nominatimOk(35.659, 139.700)); + + vi.useFakeTimers(); + const places = [ + makePlace("München"), + makePlace("Café & Bar"), + makePlace("東京タワー"), + ]; + const promise = geocodePlaces(places); + await vi.runAllTimersAsync(); + await promise; + + // Verify each name was correctly passed through URL encoding + const call0Url = new URL(mockFetch.mock.calls[0][0]); + expect(call0Url.searchParams.get("q")).toBe("München"); + + const call1Url = new URL(mockFetch.mock.calls[1][0]); + expect(call1Url.searchParams.get("q")).toBe("Café & Bar"); + + const call2Url = new URL(mockFetch.mock.calls[2][0]); + expect(call2Url.searchParams.get("q")).toBe("東京タワー"); + + vi.useRealTimers(); }); }); @@ -331,7 +359,7 @@ ); }); - it("calls onProgress for all duplicate places, not just the first", async () => { + it("calls onProgress once per unique geocode, not per duplicate place", async () => { mockFetch.mockResolvedValueOnce(nominatimOk(41.4036, 2.1744)); const onProgress = vi.fn(); @@ -341,10 +369,9 @@ ]; await geocodePlaces(places, { onProgress }); - // onProgress should be called once for the unique geocode, but reported - // for each place that shares the name + // One API call = one onProgress call, reported with the first place in the group. + // Duplicate places still get their lat/lng set, but onProgress fires once per unique query. expect(onProgress).toHaveBeenCalledTimes(1); - // The first place triggers the call expect(onProgress).toHaveBeenCalledWith( expect.objectContaining({ name: "Sagrada Familia" }), { lat: 41.4036, lng: 2.1744 } @@ -394,6 +421,20 @@ expect(result[0].lng).toBeUndefined(); }); + it("rejects non-200 responses even if body contains valid lat/lon JSON", async () => { + // A proxy or CDN could return valid-looking JSON with a non-200 status + mockFetch.mockResolvedValueOnce( + new Response( + JSON.stringify([{ lat: "99.999", lon: "99.999" }]), + { status: 500 } + ) + ); + + const result = await geocodePlaces([makePlace("Server Error Place")]); + expect(result[0].lat).toBeUndefined(); + expect(result[0].lng).toBeUndefined(); + }); + it("handles invalid JSON response body gracefully", async () => { mockFetch.mockResolvedValueOnce( new Response("Server Error", { @@ -403,6 +444,19 @@ ); const result = await geocodePlaces([makePlace("Bad Response")]); + expect(result[0].lat).toBeUndefined(); + expect(result[0].lng).toBeUndefined(); + }); + + it("rejects out-of-range coordinates from Nominatim", async () => { + mockFetch.mockResolvedValueOnce( + new Response(JSON.stringify([{ lat: "999", lon: "999" }]), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + ); + + const result = await geocodePlaces([makePlace("Bad Coords")]); expect(result[0].lat).toBeUndefined(); expect(result[0].lng).toBeUndefined(); }); @@ -453,6 +507,43 @@ expect(result[0].endLine).toBe(8); expect(result[0].lat).toBe(41.4036); expect(result[0].lng).toBe(2.1744); + }); + + it("handles mixed array: already-geocoded, needs-geocoding, and duplicates", async () => { + vi.useFakeTimers(); + // Only 2 unique names need geocoding: "The Louvre" and "Blue Bottle Coffee" + // "Sagrada Familia" already has coords, "the louvre" is a duplicate + mockFetch + .mockResolvedValueOnce(nominatimOk(48.8606, 2.3376)) // The Louvre + .mockResolvedValueOnce(nominatimOk(35.659, 139.700)); // Blue Bottle Coffee + + const places = [ + makePlace("Sagrada Familia", { lat: 41.4036, lng: 2.1744 }), + makePlace("The Louvre"), + makePlace("the louvre"), // duplicate, case-insensitive + makePlace("Blue Bottle Coffee"), + ]; + const promise = geocodePlaces(places); + await vi.runAllTimersAsync(); + const result = await promise; + + expect(result).toHaveLength(4); + // Already geocoded — unchanged + expect(result[0].lat).toBe(41.4036); + expect(result[0].lng).toBe(2.1744); + // Geocoded + expect(result[1].lat).toBe(48.8606); + expect(result[1].lng).toBe(2.3376); + // Duplicate — same result as The Louvre + expect(result[2].lat).toBe(48.8606); + expect(result[2].lng).toBe(2.3376); + // Geocoded + expect(result[3].lat).toBe(35.659); + expect(result[3].lng).toBe(139.700); + // Only 2 API calls (skip already-geocoded, dedup duplicate) + expect(mockFetch).toHaveBeenCalledTimes(2); + + vi.useRealTimers(); }); }); @@ -631,29 +722,30 @@ expect(warnCall[0]).toContain("[MapViewer]"); }); - it("shows Obsidian Notice after 3 consecutive failures", async () => { + it("calls onNotice after 3 consecutive failures", async () => { vi.useFakeTimers(); mockFetch .mockRejectedValueOnce(new Error("fail 1")) .mockRejectedValueOnce(new Error("fail 2")) .mockRejectedValueOnce(new Error("fail 3")); + const onNotice = vi.fn(); const places = [ makePlace("Bad 1"), makePlace("Bad 2"), makePlace("Bad 3"), ]; - const promise = geocodePlaces(places); + const promise = geocodePlaces(places, { onNotice }); await vi.runAllTimersAsync(); await promise; - expect(Notice).toHaveBeenCalledWith( + expect(onNotice).toHaveBeenCalledWith( "Map Viewer: Geocoding issues — check your network connection" ); vi.useRealTimers(); }); - it("shows Notice exactly once even with more than 3 consecutive failures", async () => { + it("calls onNotice exactly once even with more than 3 consecutive failures", async () => { vi.useFakeTimers(); mockFetch .mockRejectedValueOnce(new Error("fail 1")) @@ -662,6 +754,7 @@ .mockRejectedValueOnce(new Error("fail 4")) .mockRejectedValueOnce(new Error("fail 5")); + const onNotice = vi.fn(); const places = [ makePlace("Bad 1"), makePlace("Bad 2"), @@ -669,27 +762,27 @@ makePlace("Bad 4"), makePlace("Bad 5"), ]; - const promise = geocodePlaces(places); + const promise = geocodePlaces(places, { onNotice }); await vi.runAllTimersAsync(); await promise; - // Notice should fire exactly once (on the 3rd failure), not on 4th and 5th - expect(Notice).toHaveBeenCalledTimes(1); + expect(onNotice).toHaveBeenCalledTimes(1); vi.useRealTimers(); }); - it("does NOT show Notice for fewer than 3 consecutive failures", async () => { + it("does NOT call onNotice for fewer than 3 consecutive failures", async () => { vi.useFakeTimers(); mockFetch .mockRejectedValueOnce(new Error("fail 1")) .mockRejectedValueOnce(new Error("fail 2")); + const onNotice = vi.fn(); const places = [makePlace("Bad 1"), makePlace("Bad 2")]; - const promise = geocodePlaces(places); + const promise = geocodePlaces(places, { onNotice }); await vi.runAllTimersAsync(); await promise; - expect(Notice).not.toHaveBeenCalled(); + expect(onNotice).not.toHaveBeenCalled(); vi.useRealTimers(); }); @@ -702,6 +795,7 @@ .mockRejectedValueOnce(new Error("fail 3")) .mockRejectedValueOnce(new Error("fail 4")); + const onNotice = vi.fn(); const places = [ makePlace("Bad 1"), makePlace("Bad 2"), @@ -709,12 +803,32 @@ makePlace("Bad 3"), makePlace("Bad 4"), ]; - const promise = geocodePlaces(places); + const promise = geocodePlaces(places, { onNotice }); await vi.runAllTimersAsync(); await promise; // Counter was reset by "Good", so only 2 consecutive after that - expect(Notice).not.toHaveBeenCalled(); + expect(onNotice).not.toHaveBeenCalled(); + vi.useRealTimers(); + }); + + it("does not crash if onNotice is not provided", async () => { + vi.useFakeTimers(); + mockFetch + .mockRejectedValueOnce(new Error("fail 1")) + .mockRejectedValueOnce(new Error("fail 2")) + .mockRejectedValueOnce(new Error("fail 3")); + + const places = [ + makePlace("Bad 1"), + makePlace("Bad 2"), + makePlace("Bad 3"), + ]; + // No callbacks at all — should not throw + const promise = geocodePlaces(places); + await vi.runAllTimersAsync(); + await promise; + vi.useRealTimers(); }); }); diff --git a/tests/main.test.ts b/tests/main.test.ts new file mode 100644 --- /dev/null +++ b/tests/main.test.ts @@ -0,0 +1,683 @@ +/** + * main.test.ts — Tests for all main.ts behavioral contracts + * + * Mocks Obsidian Plugin API (registerView, addRibbonIcon, addCommand, + * workspace.getRightLeaf, workspace.on, vault.on), MapViewerView, and + * tests view registration, activateView singleton logic, active-leaf-change + * filtering for MarkdownView, vault modify event wiring, and lastActiveFilePath dedup. + * + * @vitest-environment jsdom + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +// ─── Mock Types ─────────────────────────────────────────────────────── + +interface MockEditor { + getCursor: ReturnType; + setCursor: ReturnType; + scrollIntoView: ReturnType; +} + +interface MockTFile { + path: string; + name: string; + basename: string; + extension: string; + stat: { ctime: number; mtime: number; size: number }; + parent: null; + vault: unknown; +} + +interface MockMarkdownView { + editor: MockEditor; + file: MockTFile | null; + getViewType: () => string; + _isMarkdownView: true; +} + +// ─── Obsidian Mock Infrastructure ───────────────────────────────────── + +type EventCallback = (...args: unknown[]) => unknown; + +class MockEvents { + private _handlers: Map> = new Map(); + + on(name: string, callback: EventCallback): { id: string } { + if (!this._handlers.has(name)) { + this._handlers.set(name, new Set()); + } + this._handlers.get(name)!.add(callback); + return { id: `${name}-${Math.random()}` }; + } + + off(name: string, callback: EventCallback): void { + this._handlers.get(name)?.delete(callback); + } + + offref(_ref: unknown): void {} + + trigger(name: string, ...data: unknown[]): void { + const handlers = this._handlers.get(name); + if (handlers) { + for (const handler of handlers) { + handler(...data); + } + } + } +} + +// ─── Module-level mock state ────────────────────────────────────────── + +let mockVault: MockEvents & { + cachedRead: ReturnType; + process: ReturnType; +}; + +let mockWorkspace: MockEvents & { + getActiveViewOfType: ReturnType; + getLeavesOfType: ReturnType; + getRightLeaf: ReturnType; + revealLeaf: ReturnType; +}; + +let mockApp: { + workspace: typeof mockWorkspace; + vault: typeof mockVault; +}; + +// Track plugin API calls +let registeredViews: Array<{ type: string; factory: (leaf: unknown) => unknown }>; +let registeredCommands: Array<{ id: string; name: string; callback: () => void }>; +let ribbonIcons: Array<{ icon: string; title: string; callback: () => void }>; +let registeredEvents: Array<{ id: string }>; + +// Mock MapViewerView +let mockRefresh: ReturnType; +let MockMapViewerViewInstances: Array<{ refresh: ReturnType; getViewType: () => string }>; + +// Mock leaf for sidebar +let mockRightLeaf: { + view: unknown; + setViewState: ReturnType; +}; + +// ─── Helpers ────────────────────────────────────────────────────────── + +function createMockFile(name = "test-note.md", path = "test-note.md"): MockTFile { + return { + path, + name, + basename: name.replace(/\.md$/, ""), + extension: "md", + stat: { ctime: Date.now(), mtime: Date.now(), size: 100 }, + parent: null, + vault: mockVault, + }; +} + +function createMockEditor(): MockEditor { + return { + getCursor: vi.fn().mockReturnValue({ line: 0, ch: 0 }), + setCursor: vi.fn(), + scrollIntoView: vi.fn(), + }; +} + +function createMockMarkdownView( + editor: MockEditor, + file: MockTFile | null +): MockMarkdownView { + return { + editor, + file, + getViewType: () => "markdown", + _isMarkdownView: true, + }; +} + +// ─── Setup / Teardown ───────────────────────────────────────────────── + +beforeEach(() => { + registeredViews = []; + registeredCommands = []; + ribbonIcons = []; + registeredEvents = []; + MockMapViewerViewInstances = []; + mockRefresh = vi.fn(); + + // Build vault mock + const vaultEvents = new MockEvents(); + mockVault = Object.assign(vaultEvents, { + cachedRead: vi.fn().mockResolvedValue(""), + process: vi.fn().mockImplementation( + async (_file: MockTFile, fn: (data: string) => string) => fn("") + ), + }); + + // Build workspace mock + const workspaceEvents = new MockEvents(); + mockWorkspace = Object.assign(workspaceEvents, { + getActiveViewOfType: vi.fn().mockReturnValue(null), + getLeavesOfType: vi.fn().mockReturnValue([]), + getRightLeaf: vi.fn(), + revealLeaf: vi.fn().mockResolvedValue(undefined), + }); + + // Build right leaf + mockRightLeaf = { + view: null, + setViewState: vi.fn().mockResolvedValue(undefined), + }; + mockWorkspace.getRightLeaf.mockReturnValue(mockRightLeaf); + + // Build app + mockApp = { + workspace: mockWorkspace, + vault: mockVault, + }; + + // ── Module mocks ── + + vi.doMock("../src/mapView", () => { + class MapViewerView { + leaf: unknown; + refresh: ReturnType; + constructor(leaf: unknown) { + this.leaf = leaf; + this.refresh = mockRefresh; + MockMapViewerViewInstances.push(this); + } + getViewType() { + return "map-viewer"; + } + } + return { + VIEW_TYPE: "map-viewer", + MapViewerView, + }; + }); + + vi.doMock("obsidian", () => { + class MarkdownView { + static _isMarkdownView = true; + editor: MockEditor; + file: MockTFile | null; + constructor() { + this.editor = createMockEditor(); + this.file = null; + } + getViewType() { + return "markdown"; + } + } + + class ItemView { + app: unknown; + leaf: unknown; + containerEl: HTMLElement; + contentEl: HTMLElement; + + constructor(leaf: unknown) { + this.leaf = leaf; + this.app = mockApp; + this.containerEl = document.createElement("div"); + this.contentEl = document.createElement("div"); + this.containerEl.appendChild(this.contentEl); + } + getViewType() { return ""; } + getDisplayText() { return ""; } + getIcon() { return ""; } + register() {} + registerEvent(ref: { id: string }) { + registeredEvents.push(ref); + } + registerInterval() { return 0; } + addChild(_c: unknown) { return _c; } + removeChild(_c: unknown) { return _c; } + onload() {} + onunload() {} + } + + class Notice { + message: string; + duration?: number; + noticeEl: HTMLElement; + constructor(message: string, duration?: number) { + this.message = message; + this.duration = duration; + this.noticeEl = document.createElement("div"); + } + hide() {} + } + + class Plugin { + app: unknown; + manifest: unknown; + constructor(app: unknown, manifest: unknown) { + this.app = app; + this.manifest = manifest; + } + registerView(type: string, factory: (leaf: unknown) => unknown) { + registeredViews.push({ type, factory }); + } + addCommand(cmd: { id: string; name: string; callback: () => void }) { + registeredCommands.push(cmd); + return cmd; + } + addRibbonIcon(icon: string, title: string, callback: () => void) { + ribbonIcons.push({ icon, title, callback }); + return document.createElement("div"); + } + register() {} + registerEvent(ref: { id: string }) { + registeredEvents.push(ref); + } + registerInterval() { return 0; } + } + + class TFile { + path = ""; + name = ""; + basename = ""; + extension = "md"; + stat = { ctime: 0, mtime: 0, size: 0 }; + parent = null; + } + + return { + ItemView, + MarkdownView, + Notice, + Plugin, + TFile, + }; + }); +}); + +afterEach(() => { + vi.restoreAllMocks(); + vi.resetModules(); +}); + +// ─── Import Helper ──────────────────────────────────────────────────── + +async function importMain() { + const mod = await import("../src/main"); + return mod; +} + +async function createPlugin() { + const mod = await importMain(); + const PluginClass = mod.default; + const plugin = new PluginClass(mockApp, { id: "map-viewer", name: "Map Viewer" }); + // Ensure plugin.app is set (Plugin constructor should do this) + (plugin as any).app = mockApp; + return { plugin, mod }; +} + +async function createAndLoadPlugin() { + const { plugin, mod } = await createPlugin(); + await plugin.onload(); + return { plugin, mod }; +} + +// ─── Tests ──────────────────────────────────────────────────────────── + +describe("main.ts — Plugin Entry", () => { + // ── Contract #1: View Registration ── + + describe("Contract #1: View Registration", () => { + it("registers view type 'map-viewer' during onload", async () => { + await createAndLoadPlugin(); + + expect(registeredViews).toHaveLength(1); + expect(registeredViews[0].type).toBe("map-viewer"); + }); + + it("view factory creates a MapViewerView instance, passing the leaf", async () => { + await createAndLoadPlugin(); + + const factory = registeredViews[0].factory; + const fakeleaf = { id: "test-leaf" }; + const view = factory(fakeleaf); + + expect(MockMapViewerViewInstances).toHaveLength(1); + expect(MockMapViewerViewInstances[0].leaf).toBe(fakeleaf); + expect(view).toBe(MockMapViewerViewInstances[0]); + }); + }); + + // ── Contract #2: Ribbon Icon ── + + describe("Contract #2: Ribbon Icon", () => { + it("adds a ribbon icon with 'map-pin' icon", async () => { + await createAndLoadPlugin(); + + expect(ribbonIcons).toHaveLength(1); + expect(ribbonIcons[0].icon).toBe("map-pin"); + }); + + it("ribbon icon click calls activateView()", async () => { + await createAndLoadPlugin(); + + // No existing map leaves + mockWorkspace.getLeavesOfType.mockReturnValue([]); + + await ribbonIcons[0].callback(); + + // Should try to find existing leaves first + expect(mockWorkspace.getLeavesOfType).toHaveBeenCalledWith("map-viewer"); + // Should create new leaf since none exists + expect(mockWorkspace.getRightLeaf).toHaveBeenCalledWith(false); + expect(mockRightLeaf.setViewState).toHaveBeenCalledWith({ + type: "map-viewer", + active: true, + }); + expect(mockWorkspace.revealLeaf).toHaveBeenCalledWith(mockRightLeaf); + }); + }); + + // ── Contract #3: Command Registration ── + + describe("Contract #3: Command Registration", () => { + it("registers command with id 'open-map-view' and name 'Open map view'", async () => { + await createAndLoadPlugin(); + + expect(registeredCommands).toHaveLength(1); + expect(registeredCommands[0].id).toBe("open-map-view"); + expect(registeredCommands[0].name).toBe("Open map view"); + }); + + it("command callback calls activateView()", async () => { + await createAndLoadPlugin(); + + mockWorkspace.getLeavesOfType.mockReturnValue([]); + + await registeredCommands[0].callback(); + + expect(mockWorkspace.getLeavesOfType).toHaveBeenCalledWith("map-viewer"); + expect(mockWorkspace.getRightLeaf).toHaveBeenCalledWith(false); + expect(mockRightLeaf.setViewState).toHaveBeenCalledWith({ + type: "map-viewer", + active: true, + }); + }); + }); + + // ── Contract #4: activateView() Singleton Logic ── + + describe("Contract #4: activateView() — Singleton Logic", () => { + it("reveals existing leaf if map-viewer leaf already exists", async () => { + await createAndLoadPlugin(); + + const existingLeaf = { view: { getViewType: () => "map-viewer" } }; + mockWorkspace.getLeavesOfType.mockReturnValue([existingLeaf]); + + await ribbonIcons[0].callback(); + + expect(mockWorkspace.getLeavesOfType).toHaveBeenCalledWith("map-viewer"); + expect(mockWorkspace.revealLeaf).toHaveBeenCalledWith(existingLeaf); + // Should NOT create a new leaf + expect(mockWorkspace.getRightLeaf).not.toHaveBeenCalled(); + }); + + it("creates new right leaf if no map-viewer leaf exists", async () => { + await createAndLoadPlugin(); + + mockWorkspace.getLeavesOfType.mockReturnValue([]); + + await ribbonIcons[0].callback(); + + expect(mockWorkspace.getRightLeaf).toHaveBeenCalledWith(false); + expect(mockRightLeaf.setViewState).toHaveBeenCalledWith({ + type: "map-viewer", + active: true, + }); + expect(mockWorkspace.revealLeaf).toHaveBeenCalledWith(mockRightLeaf); + }); + }); + + // ── Contract #5: active-leaf-change Event ── + + describe("Contract #5: active-leaf-change Event", () => { + it("registers workspace active-leaf-change event", async () => { + await createAndLoadPlugin(); + + // The plugin should have registered an event handler for active-leaf-change + // We verify by triggering the event and checking behavior + const file = createMockFile(); + const editor = createMockEditor(); + const mdView = createMockMarkdownView(editor, file); + mockWorkspace.getActiveViewOfType.mockReturnValue(mdView); + + // Find the map-viewer leaf so we can get the view's refresh method + const viewLeaf = { view: { getViewType: () => "map-viewer", refresh: mockRefresh } }; + mockWorkspace.getLeavesOfType.mockReturnValue([viewLeaf]); + + mockWorkspace.trigger("active-leaf-change", { view: mdView }); + + expect(mockRefresh).toHaveBeenCalled(); + }); + + it("only processes event when new leaf is a MarkdownView", async () => { + await createAndLoadPlugin(); + + // Set up a map-viewer leaf with refresh + const viewLeaf = { view: { getViewType: () => "map-viewer", refresh: mockRefresh } }; + mockWorkspace.getLeavesOfType.mockReturnValue([viewLeaf]); + + // Trigger with a non-MarkdownView leaf (e.g., the map sidebar itself) + mockWorkspace.getActiveViewOfType.mockReturnValue(null); + mockWorkspace.trigger("active-leaf-change", { + view: { getViewType: () => "some-other-view" }, + }); + + expect(mockRefresh).not.toHaveBeenCalled(); + }); + + it("uses lastActiveFilePath to avoid redundant refreshes for same file", async () => { + await createAndLoadPlugin(); + + const file = createMockFile("notes.md", "notes.md"); + const editor = createMockEditor(); + const mdView = createMockMarkdownView(editor, file); + mockWorkspace.getActiveViewOfType.mockReturnValue(mdView); + + const viewLeaf = { view: { getViewType: () => "map-viewer", refresh: mockRefresh } }; + mockWorkspace.getLeavesOfType.mockReturnValue([viewLeaf]); + + // First trigger — should call refresh + mockWorkspace.trigger("active-leaf-change", { view: mdView }); + expect(mockRefresh).toHaveBeenCalledTimes(1); + + // Second trigger with same file — should NOT call refresh (dedup) + mockWorkspace.trigger("active-leaf-change", { view: mdView }); + expect(mockRefresh).toHaveBeenCalledTimes(1); + }); + + it("calls refresh when switching to a different file", async () => { + await createAndLoadPlugin(); + + const file1 = createMockFile("file1.md", "file1.md"); + const file2 = createMockFile("file2.md", "file2.md"); + const editor = createMockEditor(); + + const viewLeaf = { view: { getViewType: () => "map-viewer", refresh: mockRefresh } }; + mockWorkspace.getLeavesOfType.mockReturnValue([viewLeaf]); + + // First file + const mdView1 = createMockMarkdownView(editor, file1); + mockWorkspace.getActiveViewOfType.mockReturnValue(mdView1); + mockWorkspace.trigger("active-leaf-change", { view: mdView1 }); + expect(mockRefresh).toHaveBeenCalledTimes(1); + + // Different file — should call refresh + const mdView2 = createMockMarkdownView(editor, file2); + mockWorkspace.getActiveViewOfType.mockReturnValue(mdView2); + mockWorkspace.trigger("active-leaf-change", { view: mdView2 }); + expect(mockRefresh).toHaveBeenCalledTimes(2); + }); + }); + + // ── Contract #6: vault modify Event ── + + describe("Contract #6: vault modify Event", () => { + it("registers vault modify event", async () => { + await createAndLoadPlugin(); + + const file = createMockFile(); + const editor = createMockEditor(); + const mdView = createMockMarkdownView(editor, file); + mockWorkspace.getActiveViewOfType.mockReturnValue(mdView); + + const viewLeaf = { view: { getViewType: () => "map-viewer", refresh: mockRefresh } }; + mockWorkspace.getLeavesOfType.mockReturnValue([viewLeaf]); + + // Trigger vault modify with the active file + mockVault.trigger("modify", file); + + expect(mockRefresh).toHaveBeenCalled(); + }); + + it("does not refresh if modified file is not the active file", async () => { + await createAndLoadPlugin(); + + const activeFile = createMockFile("active.md", "active.md"); + const otherFile = createMockFile("other.md", "other.md"); + const editor = createMockEditor(); + const mdView = createMockMarkdownView(editor, activeFile); + mockWorkspace.getActiveViewOfType.mockReturnValue(mdView); + + const viewLeaf = { view: { getViewType: () => "map-viewer", refresh: mockRefresh } }; + mockWorkspace.getLeavesOfType.mockReturnValue([viewLeaf]); + + // Trigger vault modify with a different file + mockVault.trigger("modify", otherFile); + + expect(mockRefresh).not.toHaveBeenCalled(); + }); + + it("does not refresh if no MarkdownView is active", async () => { + await createAndLoadPlugin(); + + mockWorkspace.getActiveViewOfType.mockReturnValue(null); + + const viewLeaf = { view: { getViewType: () => "map-viewer", refresh: mockRefresh } }; + mockWorkspace.getLeavesOfType.mockReturnValue([viewLeaf]); + + const file = createMockFile(); + mockVault.trigger("modify", file); + + expect(mockRefresh).not.toHaveBeenCalled(); + }); + }); + + // ── Contract #7: onunload() ── + + describe("Contract #7: onunload()", () => { + it("onunload exists and does not throw", async () => { + const { plugin } = await createAndLoadPlugin(); + + // Obsidian handles view deregistration automatically + // Just verify onunload doesn't throw + expect(() => plugin.onunload()).not.toThrow(); + }); + }); + + // ── Edge Cases ── + + describe("Edge Cases", () => { + it("plugin loaded with no files open — view shows empty map (no errors)", async () => { + mockWorkspace.getActiveViewOfType.mockReturnValue(null); + + // Should not throw + await createAndLoadPlugin(); + + // No refresh should have been called since there's no active view + // (The view itself handles empty state) + }); + + it("activateView called multiple times only reveals one leaf", async () => { + await createAndLoadPlugin(); + + mockWorkspace.getLeavesOfType.mockReturnValue([]); + + // First activation creates a leaf + await ribbonIcons[0].callback(); + expect(mockWorkspace.getRightLeaf).toHaveBeenCalledTimes(1); + + // Now there's an existing leaf + const existingLeaf = { view: { getViewType: () => "map-viewer" } }; + mockWorkspace.getLeavesOfType.mockReturnValue([existingLeaf]); + mockWorkspace.getRightLeaf.mockClear(); + + // Second activation should reveal existing, not create new + await ribbonIcons[0].callback(); + expect(mockWorkspace.getRightLeaf).not.toHaveBeenCalled(); + expect(mockWorkspace.revealLeaf).toHaveBeenCalledWith(existingLeaf); + }); + + it("activateView handles getRightLeaf returning null gracefully", async () => { + await createAndLoadPlugin(); + + mockWorkspace.getLeavesOfType.mockReturnValue([]); + mockWorkspace.getRightLeaf.mockReturnValue(null); + + // Should not throw + await ribbonIcons[0].callback(); + + // Should not try to call setViewState on null + expect(mockRightLeaf.setViewState).not.toHaveBeenCalled(); + }); + + it("active-leaf-change with MarkdownView where file is null always refreshes (no dedup)", async () => { + await createAndLoadPlugin(); + + const editor = createMockEditor(); + const mdViewNoFile = createMockMarkdownView(editor, null); + mockWorkspace.getActiveViewOfType.mockReturnValue(mdViewNoFile); + + const viewLeaf = { view: { getViewType: () => "map-viewer", refresh: mockRefresh } }; + mockWorkspace.getLeavesOfType.mockReturnValue([viewLeaf]); + + // First trigger with null file — should call refresh + mockWorkspace.trigger("active-leaf-change", { view: mdViewNoFile }); + expect(mockRefresh).toHaveBeenCalledTimes(1); + + // Second trigger with null file — should also refresh (no dedup for null paths) + mockWorkspace.trigger("active-leaf-change", { view: mdViewNoFile }); + expect(mockRefresh).toHaveBeenCalledTimes(2); + }); + + it("active-leaf-change with MarkdownView but no map-viewer leaf does not crash", async () => { + await createAndLoadPlugin(); + + const file = createMockFile(); + const editor = createMockEditor(); + const mdView = createMockMarkdownView(editor, file); + mockWorkspace.getActiveViewOfType.mockReturnValue(mdView); + mockWorkspace.getLeavesOfType.mockReturnValue([]); // no map view leaf + + // Should not throw, and refresh should not be called (no view to refresh) + mockWorkspace.trigger("active-leaf-change", { view: mdView }); + expect(mockRefresh).not.toHaveBeenCalled(); + }); + + it("does not crash if map-viewer leaf exists but view has no refresh method", async () => { + await createAndLoadPlugin(); + + const file = createMockFile(); + const editor = createMockEditor(); + const mdView = createMockMarkdownView(editor, file); + mockWorkspace.getActiveViewOfType.mockReturnValue(mdView); + + // Leaf exists but view has no refresh method (e.g., stale or initializing view) + const brokenLeaf = { view: { getViewType: () => "map-viewer" } }; + mockWorkspace.getLeavesOfType.mockReturnValue([brokenLeaf]); + + // Should not throw + mockWorkspace.trigger("active-leaf-change", { view: mdView }); + expect(mockRefresh).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/tests/mapRenderer.test.ts b/tests/mapRenderer.test.ts new file mode 100644 --- /dev/null +++ b/tests/mapRenderer.test.ts @@ -0,0 +1,1153 @@ +/** + * mapRenderer.test.ts — Tests for all mapRenderer.ts behavioral contracts + * + * Tests mock Leaflet, DOM APIs, and CSS variable access. + * Each contract from the spec has its own describe block. + * + * @vitest-environment jsdom + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import type { Place } from "../src/parser"; + +// ─── Helpers ────────────────────────────────────────────────────────── + +function makePlace( + name: string, + overrides: Partial = {} +): Place { + return { + name, + fields: {}, + notes: [], + startLine: 0, + endLine: 0, + ...overrides, + }; +} + +// ─── Leaflet Mock ───────────────────────────────────────────────────── + +// We build a comprehensive mock of the Leaflet APIs used by mapRenderer. +// Each test gets a fresh set of mock objects. + +interface MockMarker { + setLatLng: ReturnType; + setIcon: ReturnType; + bindPopup: ReturnType; + openPopup: ReturnType; + on: ReturnType; + off: ReturnType; + addTo: ReturnType; + remove: ReturnType; + getLatLng: ReturnType; + _latlng: { lat: number; lng: number }; + _events: Record void)[]>; +} + +interface MockCircleMarker { + addTo: ReturnType; + remove: ReturnType; + setLatLng: ReturnType; + getElement: ReturnType; +} + +interface MockMap { + setView: ReturnType; + fitBounds: ReturnType; + remove: ReturnType; + invalidateSize: ReturnType; + getZoom: ReturnType; + on: ReturnType; + off: ReturnType; + _events: Record void)[]>; +} + +interface MockTileLayer { + addTo: ReturnType; +} + +interface MockLayerGroup { + addTo: ReturnType; + clearLayers: ReturnType; + addLayer: ReturnType; + getLayers: ReturnType; + eachLayer: ReturnType; +} + +interface MockIcon { + options: Record; +} + +interface MockLatLngBounds { + extend: ReturnType; + isValid: ReturnType; + getCenter: ReturnType; + pad: ReturnType; +} + +let mockMapInstance: MockMap; +let mockMarkers: MockMarker[]; +let mockCircleMarkers: MockCircleMarker[]; +let mockTileLayer: MockTileLayer; +let mockLayerGroup: MockLayerGroup; +let mockIcons: MockIcon[]; +let mockBounds: MockLatLngBounds; + +function createMockMarker(lat: number, lng: number): MockMarker { + const marker: MockMarker = { + setLatLng: vi.fn().mockReturnThis(), + setIcon: vi.fn().mockReturnThis(), + bindPopup: vi.fn().mockReturnThis(), + openPopup: vi.fn().mockReturnThis(), + on: vi.fn().mockImplementation(function (this: MockMarker, event: string, handler: (...args: unknown[]) => void) { + if (!this._events[event]) this._events[event] = []; + this._events[event].push(handler); + return this; + }), + off: vi.fn().mockReturnThis(), + addTo: vi.fn().mockReturnThis(), + remove: vi.fn().mockReturnThis(), + getLatLng: vi.fn().mockReturnValue({ lat, lng }), + _latlng: { lat, lng }, + _events: {}, + }; + mockMarkers.push(marker); + return marker; +} + +function createMockCircleMarker(): MockCircleMarker { + const cm: MockCircleMarker = { + addTo: vi.fn().mockReturnThis(), + remove: vi.fn().mockReturnThis(), + setLatLng: vi.fn().mockReturnThis(), + getElement: vi.fn().mockReturnValue(document.createElement("div")), + }; + mockCircleMarkers.push(cm); + return cm; +} + +function setupLeafletMock(): typeof import("leaflet") { + mockMapInstance = { + setView: vi.fn().mockReturnThis(), + fitBounds: vi.fn().mockReturnThis(), + remove: vi.fn(), + invalidateSize: vi.fn(), + getZoom: vi.fn().mockReturnValue(2), + on: vi.fn().mockImplementation(function (this: MockMap, event: string, handler: (...args: unknown[]) => void) { + if (!this._events[event]) this._events[event] = []; + this._events[event].push(handler); + return this; + }), + off: vi.fn().mockReturnThis(), + _events: {}, + }; + + mockTileLayer = { + addTo: vi.fn().mockReturnThis(), + }; + + mockLayerGroup = { + addTo: vi.fn().mockReturnThis(), + clearLayers: vi.fn(), + addLayer: vi.fn(), + getLayers: vi.fn().mockReturnValue([]), + eachLayer: vi.fn(), + }; + + mockBounds = { + extend: vi.fn().mockReturnThis(), + isValid: vi.fn().mockReturnValue(true), + getCenter: vi.fn().mockReturnValue({ lat: 0, lng: 0 }), + pad: vi.fn().mockReturnThis(), + }; + + const L = { + map: vi.fn().mockReturnValue(mockMapInstance), + tileLayer: vi.fn().mockReturnValue(mockTileLayer), + layerGroup: vi.fn().mockReturnValue(mockLayerGroup), + marker: vi.fn().mockImplementation((latlng: [number, number]) => { + return createMockMarker(latlng[0], latlng[1]); + }), + circleMarker: vi.fn().mockImplementation(() => { + return createMockCircleMarker(); + }), + divIcon: vi.fn().mockImplementation((opts: Record) => { + const icon: MockIcon = { options: opts }; + mockIcons.push(icon); + return icon; + }), + icon: vi.fn().mockImplementation((opts: Record) => { + const icon: MockIcon = { options: opts }; + mockIcons.push(icon); + return icon; + }), + latLngBounds: vi.fn().mockReturnValue(mockBounds), + latLng: vi.fn().mockImplementation((lat: number, lng: number) => ({ lat, lng })), + DomEvent: { + stopPropagation: vi.fn(), + stop: vi.fn(), + }, + Util: { + stamp: vi.fn().mockReturnValue(1), + }, + }; + + return L as unknown as typeof import("leaflet"); +} + +// ─── Mock ResizeObserver ────────────────────────────────────────────── + +let resizeObserverCallback: ResizeObserverCallback | null = null; +let resizeObserverDisconnected = false; + +class MockResizeObserver { + callback: ResizeObserverCallback; + constructor(callback: ResizeObserverCallback) { + this.callback = callback; + resizeObserverCallback = callback; + resizeObserverDisconnected = false; + } + observe() {} + unobserve() {} + disconnect() { + resizeObserverDisconnected = true; + } +} + +// ─── Test Setup ─────────────────────────────────────────────────────── + +let L: ReturnType; +let createMap: typeof import("../src/mapRenderer").createMap; + +beforeEach(async () => { + mockMarkers = []; + mockCircleMarkers = []; + mockIcons = []; + + L = setupLeafletMock(); + + // Mock leaflet module + vi.doMock("leaflet", () => ({ default: L, ...L })); + + // Mock ResizeObserver globally + vi.stubGlobal("ResizeObserver", MockResizeObserver); + resizeObserverCallback = null; + resizeObserverDisconnected = false; + + // Mock getComputedStyle for CSS variable access + vi.stubGlobal( + "getComputedStyle", + vi.fn().mockReturnValue({ + getPropertyValue: vi.fn().mockImplementation((prop: string) => { + if (prop === "--interactive-accent") return "#7b6cd9"; + return ""; + }), + }) + ); + + // Import fresh module for each test + const mod = await import("../src/mapRenderer"); + createMap = mod.createMap; +}); + +afterEach(() => { + vi.restoreAllMocks(); + vi.resetModules(); + vi.unstubAllGlobals(); +}); + +// ─── Contract 1: Leaflet CSS injection ──────────────────────────────── + +describe("Contract 1: Leaflet CSS injection", () => { + it("injects a