Added a distancefrom query operator

This commit is contained in:
Erez Shermer 2026-04-18 10:47:33 +03:00
parent 96ab9a7220
commit 39c0af0ea5
8 changed files with 269 additions and 18 deletions

View file

@ -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.

View file

@ -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`

View file

@ -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`

View file

@ -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.

View file

@ -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) {

View file

@ -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: '[:]',

View file

@ -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 =

View file

@ -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:#טיול');