Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-map-gl": "^8.1.1",
"tailwindcss": "^4.3.1"
"tailwindcss": "^4.3.1",
"zod": "^4.4.3"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions src/app.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const mocks = vi.hoisted(() => {
theme: "dark",
mapStyle: "light",
externalMapProvider: "apple",
zoom: 13,
};

return {
Expand Down Expand Up @@ -101,6 +102,8 @@ describe("App settings access", () => {
"OpenStreetMap",
"Google Maps",
"Apple Maps",
"AMap",
"Baidu Maps",
]);
});
});
1 change: 1 addition & 0 deletions src/components/external-map-link.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const mocks = vi.hoisted(() => ({
theme: "eagle",
mapStyle: "auto",
externalMapProvider: "openstreetmap",
zoom: 13,
},
}));

Expand Down
22 changes: 18 additions & 4 deletions src/components/mini-map.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,16 @@ const mocks = vi.hoisted(() => {
theme: "eagle",
mapStyle: "auto",
externalMapProvider: "openstreetmap",
zoom: 13,
};

return {
settings,
isDark: false,
recordMapProps: vi.fn<(props: MockMapProps) => void>(),
updateSettings: vi.fn((patch: Partial<PluginSettings>) => {
Object.assign(settings, patch);
}),
};
});

Expand All @@ -38,7 +42,7 @@ vi.mock("../hooks/use-is-dark-theme", () => ({
vi.mock("../hooks/use-plugin-settings", () => ({
usePluginSettings: () => ({
settings: mocks.settings,
updateSettings: vi.fn(),
updateSettings: mocks.updateSettings,
}),
}));

Expand All @@ -50,11 +54,12 @@ vi.mock("react-map-gl/maplibre", () => ({
}));

beforeEach(() => {
mocks.settings = {
Object.assign(mocks.settings, {
theme: "eagle",
mapStyle: "auto",
externalMapProvider: "openstreetmap",
};
zoom: 13,
});
mocks.isDark = false;
});

Expand All @@ -63,7 +68,15 @@ afterEach(() => {
vi.clearAllMocks();
});

describe("MiniMap map style", () => {
describe("MiniMap", () => {
it("uses the persisted zoom level", () => {
mocks.settings.zoom = 16;

render(<MiniMap latitude={1.3521} longitude={103.8198} />);

expect(mocks.recordMapProps.mock.lastCall?.[0].zoom).toBe(16);
});

it.each([
["auto in a light plugin theme", "auto", false, MAP_STYLE_LIGHT],
["auto in a dark plugin theme", "auto", true, MAP_STYLE_DARK],
Expand Down Expand Up @@ -95,6 +108,7 @@ describe("MiniMap map style", () => {
mocks.settings.mapStyle = "light";
rerender(<MiniMap latitude={1.3521} longitude={103.8198} />);

expect(mocks.updateSettings).toHaveBeenCalledWith({ zoom: 14 });
expect(screen.getByTestId("map")).toBe(mapElement);
expect(mocks.recordMapProps.mock.lastCall?.[0]).toMatchObject({
latitude: 1.3521,
Expand Down
27 changes: 11 additions & 16 deletions src/components/mini-map.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { useCallback, useState } from "react";
import Map from "react-map-gl/maplibre";
import { useIsDarkTheme } from "../hooks/use-is-dark-theme";
import { usePluginSettings } from "../hooks/use-plugin-settings";
import type { MapStylePreference } from "../lib/plugin-settings";
import { MAP_ZOOM, type MapStylePreference } from "../lib/plugin-settings";
import type { Coordinates } from "../types";
import { ZoomControls } from "./zoom-controls";

Expand All @@ -15,9 +15,6 @@ const MAP_STYLE_LIGHT =
"https://basemaps.cartocdn.com/gl/positron-gl-style/style.json";
const MAP_STYLE_DARK =
"https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json";
const MIN_ZOOM = 2;
const MAX_ZOOM = 18;
const DEFAULT_ZOOM = 13;

function resolveMapStyle(
preference: MapStylePreference,
Expand All @@ -30,10 +27,10 @@ function resolveMapStyle(

export function MiniMap({ latitude, longitude, label }: MiniMapProps) {
const isDark = useIsDarkTheme();
const { settings } = usePluginSettings();
const { settings, updateSettings } = usePluginSettings();
const mapStyle = resolveMapStyle(settings.mapStyle, isDark);
const [isLoaded, setIsLoaded] = useState(false);
const [zoom, setZoom] = useState(DEFAULT_ZOOM);
const zoom = settings.zoom;

const handleLoad = useCallback(
(event: { target: { resize: () => void } }) => {
Expand All @@ -44,19 +41,17 @@ export function MiniMap({ latitude, longitude, label }: MiniMapProps) {
[],
);

const clampZoom = useCallback((value: number) => {
return Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, value));
}, []);

const handleZoomChange = useCallback(
(delta: number) => {
setZoom((prev) => clampZoom(prev + delta));
updateSettings({
zoom: Math.min(MAP_ZOOM.max, Math.max(MAP_ZOOM.min, zoom + delta)),
});
},
[clampZoom],
[updateSettings, zoom],
);

const canZoomIn = zoom < MAX_ZOOM;
const canZoomOut = zoom > MIN_ZOOM;
const canZoomIn = zoom < MAP_ZOOM.max;
const canZoomOut = zoom > MAP_ZOOM.min;

return (
<div
Expand All @@ -78,8 +73,8 @@ export function MiniMap({ latitude, longitude, label }: MiniMapProps) {
latitude={latitude}
longitude={longitude}
zoom={zoom}
minZoom={MIN_ZOOM}
maxZoom={MAX_ZOOM}
minZoom={MAP_ZOOM.min}
maxZoom={MAP_ZOOM.max}
style={{ width: "100%", height: "100%" }}
mapStyle={mapStyle}
attributionControl={false}
Expand Down
1 change: 1 addition & 0 deletions src/components/settings-menu.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const mocks = vi.hoisted(() => {
theme: "dark",
mapStyle: "light",
externalMapProvider: "apple",
zoom: 13,
};

return { settings, updateSettings: vi.fn() };
Expand Down
11 changes: 4 additions & 7 deletions src/components/settings-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,11 @@ import {
} from "react";
import { useIsDarkTheme } from "../hooks/use-is-dark-theme";
import { usePluginSettings } from "../hooks/use-plugin-settings";
import {
EXTERNAL_MAP_PROVIDER_OPTIONS,
type ExternalMapProvider,
} from "../lib/external-map";
import type {
ExternalMapProvider,
MapStylePreference,
ThemePreference,
} from "../lib/plugin-settings";
Expand Down Expand Up @@ -40,12 +43,6 @@ const MAP_STYLE_OPTIONS = [
{ value: "dark", label: "Dark" },
] as const satisfies readonly SettingOption<MapStylePreference>[];

const EXTERNAL_MAP_PROVIDER_OPTIONS = [
{ value: "openstreetmap", label: "OpenStreetMap" },
{ value: "google", label: "Google Maps" },
{ value: "apple", label: "Apple Maps" },
] as const satisfies readonly SettingOption<ExternalMapProvider>[];

const SETTINGS_DIALOG_ID = "settings-dialog";

function useDismissibleLayer(
Expand Down
3 changes: 3 additions & 0 deletions src/hooks/use-plugin-settings.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ describe("PluginSettingsProvider", () => {
theme: "dark",
mapStyle: "light",
externalMapProvider: "apple",
zoom: 16,
}),
);
const getItem = vi.spyOn(Storage.prototype, "getItem");
Expand All @@ -49,6 +50,7 @@ describe("PluginSettingsProvider", () => {
theme: "dark",
mapStyle: "light",
externalMapProvider: "apple",
zoom: 16,
});
});

Expand Down Expand Up @@ -87,6 +89,7 @@ describe("PluginSettingsProvider", () => {
theme: "eagle",
mapStyle: "dark",
externalMapProvider: "google",
zoom: 13,
}),
);
});
Expand Down
1 change: 1 addition & 0 deletions src/hooks/use-plugin-theme.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ function storeTheme(theme: ThemePreference): void {
theme,
mapStyle: "auto",
externalMapProvider: "openstreetmap",
zoom: 13,
}),
);
}
Expand Down
19 changes: 17 additions & 2 deletions src/lib/external-map.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { describe, expect, it } from "vitest";
import type { ExternalMapProvider } from "./plugin-settings";
import { getExternalMap } from "./external-map";
import { getExternalMap, type ExternalMapProvider } from "./external-map";
import type { Coordinates } from "../types";

interface ExternalMapTestCase {
Expand Down Expand Up @@ -37,6 +36,22 @@ const testCases: ExternalMapTestCase[] = [
url: "https://maps.apple.com/?ll=-33.8688%2C151.2093&z=16&q=Location",
},
},
{
provider: "amap",
coordinates: { latitude: -33.8688, longitude: 151.2093 },
expected: {
label: "AMap",
url: "https://uri.amap.com/marker?position=151.2093%2C-33.8688&name=Location&src=eagle-plugin-mini-map&coordinate=wgs84&callnative=0",
},
},
{
provider: "baidu",
coordinates: { latitude: -33.8688, longitude: 151.2093 },
expected: {
label: "Baidu Maps",
url: "https://api.map.baidu.com/marker?location=-33.8688%2C151.2093&title=Location&content=Location&output=html&coord_type=wgs84&src=webapp.eagle.mini-map",
},
},
];

describe("getExternalMap", () => {
Expand Down
101 changes: 84 additions & 17 deletions src/lib/external-map.ts
Original file line number Diff line number Diff line change
@@ -1,37 +1,104 @@
import type { ExternalMapProvider } from "./plugin-settings";
import { z } from "zod";
import type { Coordinates } from "../types";

interface ExternalMap {
label: string;
url: string;
}

export function getExternalMap(
provider: ExternalMapProvider,
coordinates: Coordinates,
): ExternalMap {
const { latitude, longitude } = coordinates;
interface ExternalMapProviderDefinition {
label: string;
buildUrl: (coordinates: Coordinates) => string;
}

switch (provider) {
case "openstreetmap": {
const EXTERNAL_MAP_PROVIDERS = {
openstreetmap: {
label: "OpenStreetMap",
buildUrl: ({ latitude, longitude }) => {
const url = new URL("https://www.openstreetmap.org/");
url.searchParams.set("mlat", String(latitude));
url.searchParams.set("mlon", String(longitude));
url.searchParams.set("zoom", "16");
return { label: "OpenStreetMap", url: url.toString() };
}
case "google": {
return url.toString();
},
},
google: {
label: "Google Maps",
buildUrl: ({ latitude, longitude }) => {
const url = new URL("https://www.google.com/maps/search/");
url.searchParams.set("api", "1");
url.searchParams.set("query", `${latitude},${longitude}`);
return { label: "Google Maps", url: url.toString() };
}
case "apple": {
return url.toString();
},
},
apple: {
label: "Apple Maps",
buildUrl: ({ latitude, longitude }) => {
const url = new URL("https://maps.apple.com/");
url.searchParams.set("ll", `${latitude},${longitude}`);
url.searchParams.set("z", "16");
url.searchParams.set("q", "Location");
return { label: "Apple Maps", url: url.toString() };
}
}
return url.toString();
},
},
amap: {
label: "AMap",
buildUrl: ({ latitude, longitude }) => {
const url = new URL("https://uri.amap.com/marker");
url.searchParams.set("position", `${longitude},${latitude}`);
url.searchParams.set("name", "Location");
url.searchParams.set("src", "eagle-plugin-mini-map");
// Treat the plugin's EXIF GPS coordinates as WGS-84; AMap otherwise
// defaults to GCJ-02.
// See: https://developer.amap.com/api/uri-api/gettingstarted
url.searchParams.set("coordinate", "wgs84");
url.searchParams.set("callnative", "0");
return url.toString();
},
},
baidu: {
label: "Baidu Maps",
buildUrl: ({ latitude, longitude }) => {
const url = new URL("https://api.map.baidu.com/marker");
url.searchParams.set("location", `${latitude},${longitude}`);
url.searchParams.set("title", "Location");
url.searchParams.set("content", "Location");
url.searchParams.set("output", "html");
// Treat the plugin's EXIF GPS coordinates as WGS-84; Baidu otherwise
// defaults to BD-09.
// See: https://lbsyun.baidu.com/faq/api?title=webapi%2Furi%2Fweb
url.searchParams.set("coord_type", "wgs84");
url.searchParams.set("src", "webapp.eagle.mini-map");
return url.toString();
},
},
} satisfies Record<string, ExternalMapProviderDefinition>;

export type ExternalMapProvider = keyof typeof EXTERNAL_MAP_PROVIDERS;

interface ExternalMapProviderOption {
readonly value: ExternalMapProvider;
readonly label: string;
}

export const EXTERNAL_MAP_PROVIDER_OPTIONS: readonly ExternalMapProviderOption[] =
Object.entries(EXTERNAL_MAP_PROVIDERS).map(([value, { label }]) => ({
value: value as ExternalMapProvider,
label,
}));

export const externalMapProviderSchema = z.enum(
EXTERNAL_MAP_PROVIDER_OPTIONS.map(({ value }) => value),
);

export function getExternalMap(
provider: ExternalMapProvider,
coordinates: Coordinates,
): ExternalMap {
const definition = EXTERNAL_MAP_PROVIDERS[provider];

return {
label: definition.label,
url: definition.buildUrl(coordinates),
};
}
Loading
Loading