mirror of
https://github.com/esm7/obsidian-map-view.git
synced 2026-07-22 05:40:27 +00:00
Added a distancefrom query operator
This commit is contained in:
parent
96ab9a7220
commit
39c0af0ea5
8 changed files with 269 additions and 18 deletions
|
|
@ -6,6 +6,11 @@ All notable changes to Map View are documented here.
|
|||
|
||||
## Unreleased
|
||||
|
||||
### Added
|
||||
|
||||
- A new `distancefrom` query operator.
|
||||
- New CLI commands for running queries and calculating routes, especially for usage in conjunction with an LLM.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed issues with saving a new default preset and a few other UI quirks.
|
||||
|
|
|
|||
18
docs/cli.md
18
docs/cli.md
|
|
@ -125,14 +125,16 @@ Each line contains the marker's display name, coordinates, and the path of the s
|
|||
|
||||
**Query syntax examples:**
|
||||
|
||||
| Query | Meaning |
|
||||
| ------------------------------ | ----------------------------------------------- |
|
||||
| `tag:#hiking` | Markers tagged `#hiking` |
|
||||
| `path:France` | Markers from notes whose path contains "France" |
|
||||
| `tag:#cafe AND path:Paris` | Cafés in Paris notes |
|
||||
| `name:Louvre` | Markers whose display name contains "Louvre" |
|
||||
| `tag:#restaurant OR tag:#cafe` | Restaurants or cafés |
|
||||
| _(empty)_ | All markers |
|
||||
| Query | Meaning |
|
||||
| --------------------------------------------- | ------------------------------------------------ |
|
||||
| `tag:#hiking` | Markers tagged `#hiking` |
|
||||
| `path:France` | Markers from notes whose path contains "France" |
|
||||
| `tag:#cafe AND path:Paris` | Cafés in Paris notes |
|
||||
| `name:Louvre` | Markers whose display name contains "Louvre" |
|
||||
| `tag:#restaurant OR tag:#cafe` | Restaurants or cafés |
|
||||
| `distancefrom:48.85,2.35<2km` | Markers within 2 km of a point (aerial distance) |
|
||||
| `distancefrom:32.08,34.78<500m AND tag:#cafe` | Cafés within 500 m of a point |
|
||||
| _(empty)_ | All markers |
|
||||
|
||||
### `mv-focus-note`
|
||||
|
||||
|
|
|
|||
|
|
@ -6,15 +6,16 @@ Map View supports powerful queries to filter what is shown on the map.
|
|||
|
||||
## Search Operators
|
||||
|
||||
| Operator | Description |
|
||||
| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `tag:#...` | Notes or markers tagged with a specific tag. Works on both note tags (`#hiking`) and inline tags (`tag:hiking`). Supports wildcards: `tag:#sleep*` matches `#sleep` and `#sleep/camping`. |
|
||||
| `name:...` | Markers whose name contains the given string. For front-matter geolocations this matches the file name. For inline geolocations this matches the link name and **ignores** the file name. |
|
||||
| `path:...` | Notes whose path matches the query. Includes all markers in matching notes. |
|
||||
| `linkedto:...` | Notes that contain a link to the specified note. E.g. `linkedto:"Cave Hikes"` includes all notes that link to `[[Cave Hikes]]`. |
|
||||
| `linkedfrom:...` | Notes that are linked from the specified note (plus the origin note itself). E.g. `linkedfrom:"Trip to Italy"` includes all notes linked from `[[Trip to Italy]]`. |
|
||||
| `["property":"value"]` | Notes with the property `property` set to `value`. |
|
||||
| `lines:x-y` | Only inline markers defined in the given line range in their note. E.g. `lines:20-30`. |
|
||||
| Operator | Description |
|
||||
| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `tag:#...` | Notes or markers tagged with a specific tag. Works on both note tags (`#hiking`) and inline tags (`tag:hiking`). Supports wildcards: `tag:#sleep*` matches `#sleep` and `#sleep/camping`. |
|
||||
| `name:...` | Markers whose name contains the given string. For front-matter geolocations this matches the file name. For inline geolocations this matches the link name and **ignores** the file name. |
|
||||
| `path:...` | Notes whose path matches the query. Includes all markers in matching notes. |
|
||||
| `linkedto:...` | Notes that contain a link to the specified note. E.g. `linkedto:"Cave Hikes"` includes all notes that link to `[[Cave Hikes]]`. |
|
||||
| `linkedfrom:...` | Notes that are linked from the specified note (plus the origin note itself). E.g. `linkedfrom:"Trip to Italy"` includes all notes linked from `[[Trip to Italy]]`. |
|
||||
| `["property":"value"]` | Notes with the property `property` set to `value`. |
|
||||
| `lines:x-y` | Only inline markers defined in the given line range in their note. E.g. `lines:20-30`. |
|
||||
| `distancefrom:lat,lng<radius` | Only markers within a given aerial (straight-line) distance from the given coordinates. Radius can be in `km`, `m`, `mi`, or `ft`. E.g. `distancefrom:32.08,34.78<5km`, `distancefrom:40.71,-74.00<0.5mi`, `distancefrom:40.71,-74.00<500m`. Brackets around the coordinates are optional. |
|
||||
|
||||
All operators are **case insensitive**.
|
||||
|
||||
|
|
@ -57,6 +58,26 @@ tag:#hike AND (tag:#dogs OR tag:#amazing) AND NOT path:"bad places"
|
|||
|
||||
Great hikes (dogs-OK or amazing), excluding notes in the "bad places" path.
|
||||
|
||||
```
|
||||
distancefrom:48.8566,2.3522<2km AND tag:#restaurant
|
||||
```
|
||||
|
||||
Restaurants within 2 km of central Paris.
|
||||
|
||||
```
|
||||
distancefrom:32.08,34.78<500m
|
||||
```
|
||||
|
||||
All markers within 500 metres of a reference point.
|
||||
|
||||
```
|
||||
tag:#cafe AND (distancefrom:40.71,-74.00<1mi OR distancefrom:40.75,-73.99<1mi)
|
||||
```
|
||||
|
||||
Cafés within 1 mile of either of two reference points.
|
||||
|
||||
**Tip:** `distancefrom` works best in conjunction with an LLM that utilizes the [CLI](cli.md) and [Skill](https://github.com/esm7/obsidian-map-view/tree/master/skills/map-view).
|
||||
|
||||
## Creative Uses
|
||||
|
||||
- Represent location types with tags: `#hike`, `#restaurant`, `#camping`
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
---
|
||||
name: map-view
|
||||
description: Look up and record geolocations in Obsidian notes using the Map View plugin. Use as part of helping the user to plan a trip, research places to visit, record a location in a note, or whenever the content being added to a note includes named real-world places. Every time the user asks you add to a note something that is a location, consider using this skill so it will be logged as a geolocation. Also use when the user refers to a known place ("my home", "my office", "my hotel") — query their vault to find its coordinates, then use those for distance or routing calculations. Another usage is to measure distances, either aerial or by routing (foot/driving/cycling etc), e.g. for researching for places that are nearby a location, within a walking distance from it, within a driving distance, etc.
|
||||
description: Look up and record geolocations in Obsidian notes using the Map View plugin. Use as part of helping the user to plan a trip, research places to visit, record a location in a note, or whenever the content being added to a note includes named real-world places. Every time the user asks you add to a note something that is a location, consider using this skill so it will be logged as a geolocation. Also use when the user refers to a known place ("my home", "my office", "my hotel") — query their vault to find its coordinates, then use with other commands. Another usage is to measure distances, either aerial or by routing (foot/driving/cycling etc), e.g. for researching for places that are nearby a location, within a walking distance from it, within a driving distance, etc. Or to query for places around a point with the distancefrom operator.
|
||||
---
|
||||
|
||||
# Map View
|
||||
|
|
@ -67,6 +67,15 @@ obsidian mv-calc-route from="lat,lng" to="lat,lng" profile="foot"
|
|||
|
||||
Returns routed distance, travel time in minutes, and elevation change (ascent/descent) using the routing engine configured in Map View settings (GraphHopper — requires an API key). Does **not** return path geometry. Available profiles: `foot`, `bike`, `car`, `hike`, `motorcycle`, `racingbike`, `mtb`. Coordinates can be given as `lat,lng` or `[lat,lng]` — the brackets are optional.
|
||||
|
||||
Take special notice of the **`distancefrom:` query operator** that filters markers by aerial distance without any API call:
|
||||
|
||||
```bash
|
||||
obsidian mv-query "distancefrom:32.08,34.78<5km"
|
||||
obsidian mv-query "distancefrom:32.08,34.78<500m AND tag:#restaurant"
|
||||
```
|
||||
|
||||
Radius can be in `km`, `m`, `mi`, or `ft`. This is the fastest way to find vault markers near a given point — no routing API key required. Use it to quickly narrow down candidates before calling `mv-calc-route` only on the ones that are geographically plausible, or to answer the users for queries like "what do I have in my vault that is around...".
|
||||
|
||||
## Checking distance and travel time for recommendations
|
||||
|
||||
Use `mv-calc-distance` or `mv-calc-route` to verify that places meet a proximity criterion before presenting them to the user.
|
||||
|
|
|
|||
26
src/query.ts
26
src/query.ts
|
|
@ -1,4 +1,5 @@
|
|||
import { App } from 'obsidian';
|
||||
import * as leaflet from 'leaflet';
|
||||
|
||||
import * as regex from 'src/regex';
|
||||
import { isLayerLinkedFrom } from 'src/fileMarker';
|
||||
|
|
@ -40,6 +41,7 @@ export class Query {
|
|||
.replace(regex.LINKEDTO_QUERY_WITH_HEADER, '"linkedto:$1"')
|
||||
.replace(regex.LINKEDFROM_QUERY_WITH_HEADER, '"linkedfrom:$1"')
|
||||
.replace(regex.NAME_QUERY_WITH_HEADER, '"name:$1"')
|
||||
.replace(regex.DISTANCEFROM_QUERY_WITH_HEADER, '"distancefrom:$1"')
|
||||
.replace(/^\[(")(.+?)\1:/, "['$2':")
|
||||
.replace(/:(")(.+)?\1\]/, ":'$2']");
|
||||
return newString;
|
||||
|
|
@ -142,6 +144,30 @@ export class Query {
|
|||
// Also include the 'linked from' file itself
|
||||
if (fileMatch.basename === layer.file.basename) return true;
|
||||
}
|
||||
} else if (value.startsWith('distancefrom:')) {
|
||||
const match = value.match(
|
||||
/^distancefrom:\[?(-?[\d.]+),(-?[\d.]+)\]?<([\d.]+)(km|mi|ft|m)$/,
|
||||
);
|
||||
if (!match) return false;
|
||||
const lat = parseFloat(match[1]);
|
||||
const lng = parseFloat(match[2]);
|
||||
const radius = parseFloat(match[3]);
|
||||
const unit = match[4];
|
||||
const radiusMeters =
|
||||
unit === 'km'
|
||||
? radius * 1000
|
||||
: unit === 'mi'
|
||||
? radius * 1609.344
|
||||
: unit === 'ft'
|
||||
? radius * 0.3048
|
||||
: radius;
|
||||
const location = (layer as any).location as
|
||||
| leaflet.LatLngExpression
|
||||
| undefined;
|
||||
if (!location) return false;
|
||||
return (
|
||||
leaflet.latLng(lat, lng).distanceTo(location) <= radiusMeters
|
||||
);
|
||||
} else if (value.startsWith('lines:')) {
|
||||
const linesQueryMatch = value.match(/(lines:)([0-9]+)-([0-9]+)/);
|
||||
if (linesQueryMatch && linesQueryMatch.length === 4) {
|
||||
|
|
|
|||
|
|
@ -197,6 +197,10 @@ export class QuerySuggest extends PopoverSuggest<Suggestion> {
|
|||
textToInsert: 'linkedfrom:""',
|
||||
cursorOffset: -1,
|
||||
},
|
||||
{
|
||||
text: 'distancefrom:lat,lng<radius (e.g. distancefrom:32.08,34.78<5km)',
|
||||
textToInsert: 'distancefrom:',
|
||||
},
|
||||
{
|
||||
text: '["property":"value"]',
|
||||
textToInsert: '[:]',
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@ export const QUOTED_OR_NOT_QUOTED_PATH =
|
|||
/path:(("((?:[^"]|\\")*)")|((?:[^"\s]|\\")*))/gu;
|
||||
export const QUOTED_OR_NOT_QUOTED_LINKEDTO =
|
||||
/linkedto:(("((?:[^"]|\\")*)")|((?:[^"\s]|\\")*))/gu;
|
||||
export const DISTANCEFROM_QUERY_WITH_HEADER =
|
||||
/distancefrom:(\[?[+-]?[\d.]+,[+-]?[\d.]+\]?<[\d.]+(?:km|mi|ft|m))/gu;
|
||||
export const QUOTED_OR_NOT_QUOTED_LINKEDFROM =
|
||||
/linkedfrom:(("((?:[^"]|\\")*)")|((?:[^"\s]|\\")*))/gu;
|
||||
export const COORDINATES =
|
||||
|
|
|
|||
|
|
@ -185,6 +185,188 @@ describe('Query.testLayer', () => {
|
|||
).toBe(false);
|
||||
});
|
||||
|
||||
// distancefrom operator
|
||||
// 1 degree latitude ≈ 111 km; test coordinates use lat offsets to produce known distances.
|
||||
it('distancefrom matches layer exactly at the reference point', () => {
|
||||
const q = new Query(null as any, 'distancefrom:32.0,34.0<1m');
|
||||
expect(
|
||||
q.testLayer(mockLayer({ location: { lat: 32.0, lng: 34.0 } })),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('distancefrom matches layer within radius in km', () => {
|
||||
// lat offset 0.04° ≈ 4.4 km, well within 5 km
|
||||
const q = new Query(null as any, 'distancefrom:32.0,34.0<5km');
|
||||
expect(
|
||||
q.testLayer(mockLayer({ location: { lat: 32.04, lng: 34.0 } })),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('distancefrom does not match layer outside radius in km', () => {
|
||||
// lat offset 0.06° ≈ 6.7 km, outside 5 km
|
||||
const q = new Query(null as any, 'distancefrom:32.0,34.0<5km');
|
||||
expect(
|
||||
q.testLayer(mockLayer({ location: { lat: 32.06, lng: 34.0 } })),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('distancefrom matches layer within radius in meters', () => {
|
||||
// lat offset 0.003° ≈ 333 m, within 500 m
|
||||
const q = new Query(null as any, 'distancefrom:32.0,34.0<500m');
|
||||
expect(
|
||||
q.testLayer(mockLayer({ location: { lat: 32.003, lng: 34.0 } })),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('distancefrom does not match layer outside radius in meters', () => {
|
||||
// lat offset 0.01° ≈ 1111 m, outside 500 m
|
||||
const q = new Query(null as any, 'distancefrom:32.0,34.0<500m');
|
||||
expect(
|
||||
q.testLayer(mockLayer({ location: { lat: 32.01, lng: 34.0 } })),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('distancefrom returns false for layer without location', () => {
|
||||
const q = new Query(null as any, 'distancefrom:32.0,34.0<5km');
|
||||
expect(q.testLayer(mockLayer())).toBe(false);
|
||||
});
|
||||
|
||||
it('distancefrom works with negative coordinates', () => {
|
||||
// NYC area — layer at same point
|
||||
const q = new Query(null as any, 'distancefrom:40.71,-74.00<1km');
|
||||
expect(
|
||||
q.testLayer(mockLayer({ location: { lat: 40.71, lng: -74.0 } })),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('distancefrom works with optional brackets', () => {
|
||||
const q = new Query(null as any, 'distancefrom:[32.0,34.0]<5km');
|
||||
expect(
|
||||
q.testLayer(mockLayer({ location: { lat: 32.04, lng: 34.0 } })),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('distancefrom composes with AND', () => {
|
||||
const q = new Query(
|
||||
null as any,
|
||||
'distancefrom:32.0,34.0<5km AND tag:#hiking',
|
||||
);
|
||||
expect(
|
||||
q.testLayer(
|
||||
mockLayer({
|
||||
location: { lat: 32.04, lng: 34.0 },
|
||||
tags: ['#hiking'],
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
q.testLayer(
|
||||
mockLayer({
|
||||
location: { lat: 32.04, lng: 34.0 },
|
||||
tags: ['#food'],
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('distancefrom matches within radius in miles', () => {
|
||||
// 0.04° lat ≈ 4.4 km ≈ 2.7 mi, within 3 mi
|
||||
const q = new Query(null as any, 'distancefrom:32.0,34.0<3mi');
|
||||
expect(
|
||||
q.testLayer(mockLayer({ location: { lat: 32.04, lng: 34.0 } })),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('distancefrom does not match outside radius in miles', () => {
|
||||
// 0.06° lat ≈ 6.7 km ≈ 4.2 mi, outside 3 mi
|
||||
const q = new Query(null as any, 'distancefrom:32.0,34.0<3mi');
|
||||
expect(
|
||||
q.testLayer(mockLayer({ location: { lat: 32.06, lng: 34.0 } })),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('distancefrom matches within radius in feet', () => {
|
||||
// 0.003° lat ≈ 333 m ≈ 1093 ft, within 1500 ft
|
||||
const q = new Query(null as any, 'distancefrom:32.0,34.0<1500ft');
|
||||
expect(
|
||||
q.testLayer(mockLayer({ location: { lat: 32.003, lng: 34.0 } })),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('distancefrom does not match outside radius in feet', () => {
|
||||
// 0.01° lat ≈ 1111 m ≈ 3645 ft, outside 1500 ft
|
||||
const q = new Query(null as any, 'distancefrom:32.0,34.0<1500ft');
|
||||
expect(
|
||||
q.testLayer(mockLayer({ location: { lat: 32.01, lng: 34.0 } })),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('distancefrom composes with NOT', () => {
|
||||
const q = new Query(null as any, 'NOT distancefrom:32.0,34.0<5km');
|
||||
// Within 5 km → NOT → false
|
||||
expect(
|
||||
q.testLayer(mockLayer({ location: { lat: 32.04, lng: 34.0 } })),
|
||||
).toBe(false);
|
||||
// Outside 5 km → NOT → true
|
||||
expect(
|
||||
q.testLayer(mockLayer({ location: { lat: 32.06, lng: 34.0 } })),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('distancefrom composes with OR', () => {
|
||||
// Two separate reference points; layer is near the second one only
|
||||
const q = new Query(
|
||||
null as any,
|
||||
'distancefrom:32.0,34.0<1km OR distancefrom:33.0,34.0<1km',
|
||||
);
|
||||
// Near first point
|
||||
expect(
|
||||
q.testLayer(mockLayer({ location: { lat: 32.005, lng: 34.0 } })),
|
||||
).toBe(true);
|
||||
// Near second point
|
||||
expect(
|
||||
q.testLayer(mockLayer({ location: { lat: 33.005, lng: 34.0 } })),
|
||||
).toBe(true);
|
||||
// Near neither
|
||||
expect(
|
||||
q.testLayer(mockLayer({ location: { lat: 32.5, lng: 34.0 } })),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('distancefrom works inside parentheses', () => {
|
||||
const q = new Query(
|
||||
null as any,
|
||||
'tag:#cafe AND (distancefrom:32.0,34.0<2km OR distancefrom:33.0,34.0<2km)',
|
||||
);
|
||||
// Café near first point — matches
|
||||
expect(
|
||||
q.testLayer(
|
||||
mockLayer({
|
||||
location: { lat: 32.01, lng: 34.0 },
|
||||
tags: ['#cafe'],
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
// Café near neither point — does not match
|
||||
expect(
|
||||
q.testLayer(
|
||||
mockLayer({
|
||||
location: { lat: 32.5, lng: 34.0 },
|
||||
tags: ['#cafe'],
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
// Near first point but wrong tag — does not match
|
||||
expect(
|
||||
q.testLayer(
|
||||
mockLayer({
|
||||
location: { lat: 32.01, lng: 34.0 },
|
||||
tags: ['#restaurant'],
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
// Unicode / emoji query cases
|
||||
it('Hebrew tag query matches layer with Hebrew tag', () => {
|
||||
const q = new Query(null as any, 'tag:#טיול');
|
||||
|
|
|
|||
Loading…
Reference in a new issue