Skip to content

Commit 6e4bb21

Browse files
authored
Merge pull request #11 from jdorrance/codex/windy-point-clicks
Keep the AV pane open while selecting map points
2 parents 903a23a + 79adeb6 commit 6e4bb21

4 files changed

Lines changed: 67 additions & 23 deletions

File tree

‎apps/windy-plugin/src/bundle.test.ts‎

Lines changed: 50 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ afterEach(() => {
5050
it.each([
5151
['configured', 'https://api.example.test:9443/prefix'],
5252
['default', undefined]
53-
])('loads the built Windy module with %s API base, shows stale horizons and tile errors, normalizes clicks, cancels stale requests, and expires a pending series', async (_name, configuredBase) => {
53+
])('loads the built Windy module with %s API base, routes clicks without closing its pane, shows stale horizons and tile errors, cancels stale requests, and expires a pending series', async (_name, configuredBase) => {
5454
const pluginDir = resolve(import.meta.dirname, '..');
5555
const vite = resolve(pluginDir, 'node_modules/vite/bin/vite.js');
5656
const base = configuredBase ?? 'http://localhost:8787';
@@ -101,7 +101,22 @@ it.each([
101101
sampleSignal = init?.signal;
102102
return new Promise(resolve => { resolveSample = resolve; });
103103
});
104-
let click: ((event: { latlng: { lat: number; lng: number } }) => void) | undefined;
104+
const clickListeners = new Map<string, Set<(point: WindySingleclickPoint) => void>>();
105+
const singleclick = {
106+
on: vi.fn((name: string, listener: (point: WindySingleclickPoint) => void) => {
107+
const listeners = clickListeners.get(name) ?? new Set<(point: WindySingleclickPoint) => void>();
108+
listeners.add(listener);
109+
clickListeners.set(name, listeners);
110+
}),
111+
off: vi.fn((name: string, listener: (point: WindySingleclickPoint) => void) => {
112+
const listeners = clickListeners.get(name);
113+
listeners?.delete(listener);
114+
if (listeners?.size === 0) clickListeners.delete(name);
115+
}),
116+
emit(name: string, point: WindySingleclickPoint): void {
117+
for (const listener of clickListeners.get(name) ?? []) listener(point);
118+
}
119+
};
105120
const tileListeners = new Map<string, (event: { tile: object }) => void>();
106121
const source = {
107122
on: vi.fn((name: string, listener: (event: { tile: object }) => void) => tileListeners.set(name, listener)),
@@ -111,7 +126,7 @@ it.each([
111126
url: '', setUrl(url: string) { this.url = url; }, setOpacity: vi.fn(),
112127
source: () => source
113128
};
114-
const map = { on: vi.fn((_name, listener) => { click = listener; }), off: vi.fn(), addLayer: vi.fn(), removeLayer: vi.fn() };
129+
const map = { on: vi.fn(), off: vi.fn(), addLayer: vi.fn(), removeLayer: vi.fn() };
115130
const store = { get: () => hostTime, set: vi.fn(), on: vi.fn(), off: vi.fn() };
116131
let destroyApp: (() => void) | undefined;
117132
const broadcast = { emit: vi.fn((event: string, name: string) => {
@@ -121,7 +136,7 @@ it.each([
121136
vi.stubGlobal('document', document);
122137
vi.stubGlobal('Option', FakeOption);
123138
vi.stubGlobal('fetch', fetchImpl);
124-
vi.stubGlobal('W', { map: { map }, store, broadcast });
139+
vi.stubGlobal('W', { map: { map }, singleclick: { singleclick }, store, broadcast });
125140
const protocols = new Map<string, unknown>();
126141
vi.stubGlobal('L', {
127142
tileLayer: (url: string) => { layer.url = url; return layer; },
@@ -133,6 +148,16 @@ it.each([
133148
expect(loaded.__pluginConfig.name).toBe('windy-plugin-av-layer');
134149
expect(typeof loaded.default).toBe('function');
135150
const metadata = JSON.parse(readFileSync(resolve(pluginDir, 'dist/plugin.json'), 'utf8'));
151+
const openWeatherPicker = vi.fn(() => destroyApp?.());
152+
const click = (point: WindySingleclickPoint): void => {
153+
// Windy routes claimed clicks to the open plugin; otherwise its weather
154+
// picker opens and dismisses a conflicting native pane.
155+
if (metadata.listenToSingleclick && clickListeners.has(metadata.name)) {
156+
singleclick.emit(metadata.name, point);
157+
} else {
158+
openWeatherPicker();
159+
}
160+
};
136161
const installPackage = JSON.parse(readFileSync(resolve(pluginDir, 'dist/package.json'), 'utf8'));
137162
expect(metadata).toEqual(loaded.__pluginConfig);
138163
expect(installPackage).toEqual({ name: metadata.name, version: metadata.version, type: 'module', main: 'plugin.min.js' });
@@ -144,11 +169,15 @@ it.each([
144169
expect(target.children[1].className.split(' ')).toContain('plugin__content');
145170
expect(fetchImpl).not.toHaveBeenCalled();
146171
expect(map.on).not.toHaveBeenCalled();
172+
expect(singleclick.on).not.toHaveBeenCalled();
147173
destroyApp = () => app.$destroy();
148174
expect(typeof app.onopen).toBe('function');
149175
expect(typeof app.$destroy).toBe('function');
150176
app.onopen();
151177
await vi.waitFor(() => expect(map.addLayer).toHaveBeenCalledTimes(1));
178+
expect(singleclick.on).toHaveBeenCalledWith(metadata.name, expect.any(Function));
179+
expect(clickListeners.get(metadata.name)?.size).toBe(1);
180+
expect(map.on).not.toHaveBeenCalled();
152181
expect(fetchImpl.mock.calls[0][0]).toBe(`${base}/v1/models/gfs/latest?expand=manifest`);
153182
expect(layer.url).toContain('/tiles/gfs/b1/precip-rate/f006/');
154183
expect(protocols.size).toBe(1);
@@ -175,18 +204,22 @@ it.each([
175204
selects[2].dispatch('change');
176205
expect(store.set).toHaveBeenCalledWith('timestamp', Date.parse('2026-10-30T03:00:00.000Z'));
177206

178-
click?.({ latlng: { lat: 1, lng: 190 } });
207+
click({ lat: 1, lon: 190 });
208+
expect(openWeatherPicker).not.toHaveBeenCalled();
209+
expect(target.children).toHaveLength(2);
210+
expect(map.removeLayer).not.toHaveBeenCalled();
179211
const button = elements.find(element => element.textContent === 'Sample clicked point')!;
180212
button.dispatch('click');
181213
expect(sampleSignal?.aborted).toBe(false);
214+
expect(fetchImpl.mock.calls[1][0]).toContain('lat=1');
182215
expect(fetchImpl.mock.calls[1][0]).toContain('lon=-170');
183216
resolveSample(new Response(JSON.stringify({ model: 'gfs', buildId: 'b1', lat: 1, lon: -170, method: manifest.method, source: manifest.source, coverage: manifest.coverage, expiresAt, frames: frames.map(frame => ({ ...frame, layers: { 'precip-rate': { value: 1, status: 'valid', support: 0, interpolated: false } } })) })));
184217
const series = elements.find(element => element.className === 'av-series')!;
185218
await vi.waitFor(() => expect(series.children).toHaveLength(1));
186219

187220
button.dispatch('click');
188221
const firstSignal = sampleSignal;
189-
click?.({ latlng: { lat: 2, lng: 3 } });
222+
click({ lat: 2, lon: 3 });
190223
expect(firstSignal?.aborted).toBe(true);
191224
expect(series.children).toHaveLength(0);
192225

@@ -205,11 +238,13 @@ it.each([
205238
await Promise.resolve();
206239
expect(series.children).toHaveLength(0);
207240
app.$destroy();
208-
expect(map.off).toHaveBeenCalledTimes(1);
241+
expect(singleclick.off).toHaveBeenCalledTimes(1);
242+
expect(clickListeners.size).toBe(0);
243+
expect(map.off).not.toHaveBeenCalled();
209244
expect(target.children).toHaveLength(0);
210245

211246
// A live view must close before opening the menu, releasing its
212-
// pending point request, time subscription, map listener and raster protocol.
247+
// pending point request, time subscription, click listener and raster protocol.
213248
clock.mockReturnValue(now);
214249
const previousElementCount = elements.length;
215250
const reopened = new loaded.default({ target, anchor: target });
@@ -224,7 +259,8 @@ it.each([
224259
expect(target.children).toEqual([mobileHeader, root]);
225260
expect(target.children).toContain(root);
226261
expect(document.body.children).not.toContain(root);
227-
click?.({ latlng: { lat: 3, lng: 4 } });
262+
expect(clickListeners.get(metadata.name)?.size).toBe(1);
263+
click({ lat: 3, lon: 4 });
228264
reopenedElements.find(element => element.textContent === 'Sample clicked point')!.dispatch('click');
229265
expect(sampleSignal?.aborted).toBe(false);
230266
expect(protocols.size).toBe(1);
@@ -234,7 +270,11 @@ it.each([
234270
['rqstOpen', 'menu']
235271
]);
236272
expect(sampleSignal?.aborted).toBe(true);
237-
expect(map.off).toHaveBeenCalledTimes(2);
273+
expect(singleclick.off).toHaveBeenCalledTimes(2);
274+
expect(clickListeners.size).toBe(0);
275+
expect(map.on).not.toHaveBeenCalled();
276+
expect(map.off).not.toHaveBeenCalled();
277+
expect(openWeatherPicker).not.toHaveBeenCalled();
238278
expect(store.off).toHaveBeenCalledTimes(2);
239279
expect(map.removeLayer).toHaveBeenCalledTimes(2);
240280
expect(tileListeners.size).toBe(0);

‎apps/windy-plugin/src/plugin.ts‎

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,14 @@ import { PluginPanel } from './view';
55
import { WindyRasterLayer } from './raster-layer';
66

77
const { map } = W.map;
8+
const { singleclick } = W.singleclick;
89
const { store, broadcast: bcast } = W;
910
const API_BASE = import.meta.env.AV_API_BASE || 'http://localhost:8787';
1011
const api = new ApiClient({ baseUrl: API_BASE });
1112
let panel: PluginPanel | null = null;
1213
let controller: PluginController | null = null;
1314
let seriesAbort: AbortController | null = null;
14-
let mapClickBound = false;
15+
let singleclickBound = false;
1516
let mountTarget: HTMLElement | null = null;
1617

1718
const host: WindyHost = {
@@ -23,11 +24,10 @@ const host: WindyHost = {
2324
createTileLayer: (url, options) => new WindyRasterLayer(L, API_BASE, url, options)
2425
};
2526

26-
function onClick(event: { latlng?: { lat: number; lng: number } }): void {
27-
if (!event.latlng) return;
27+
function onClick({ lat, lon }: WindySingleclickPoint): void {
2828
seriesAbort?.abort();
2929
seriesAbort = null;
30-
const point = normalizePoint(event.latlng.lat, event.latlng.lng);
30+
const point = normalizePoint(lat, lon);
3131
panel?.setClicked(point.lat, point.lon);
3232
panel?.setSeriesEnabled(controller?.state().phase === 'ready');
3333
}
@@ -144,9 +144,9 @@ async function startController(model: ModelId): Promise<void> {
144144
}
145145

146146
export const onopen = (_params: unknown): void => {
147-
if (!mapClickBound) {
148-
map.on('click', onClick);
149-
mapClickBound = true;
147+
if (!singleclickBound) {
148+
singleclick.on(__pluginConfig.name, onClick);
149+
singleclickBound = true;
150150
}
151151
void startController('gfs');
152152
};
@@ -156,9 +156,9 @@ export function onclose(): void {
156156
seriesAbort = null;
157157
controller?.dispose();
158158
controller = null;
159-
if (mapClickBound) {
160-
map.off('click', onClick);
161-
mapClickBound = false;
159+
if (singleclickBound) {
160+
singleclick.off(__pluginConfig.name, onClick);
161+
singleclickBound = false;
162162
}
163163
panel?.setVisible(false);
164164
}

‎apps/windy-plugin/src/pluginConfig.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ const config = {
88
repository: 'https://github.com/jdorrance/av-layers',
99
desktopUI: 'rhpane',
1010
mobileUI: 'fullscreen',
11+
listenToSingleclick: true,
1112
routerPath: '/av-layer',
1213
private: true
1314
} as const;

‎apps/windy-plugin/src/windy.d.ts‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
/// <reference types="vite/client" />
22

3+
type WindySingleclickPoint = { lat: number; lon: number };
4+
35
declare const W: {
4-
map: { map: import('./raster-layer').NativeRasterMap & {
5-
on(event: 'click', listener: (event: { latlng?: { lat: number; lng: number } }) => void): unknown;
6-
off(event: 'click', listener: (event: { latlng?: { lat: number; lng: number } }) => void): unknown;
6+
map: { map: import('./raster-layer').NativeRasterMap };
7+
singleclick: { singleclick: {
8+
on(name: string, listener: (point: WindySingleclickPoint) => void): unknown;
9+
off(name: string, listener: (point: WindySingleclickPoint) => void): unknown;
710
} };
811
store: import('./controller').HostStore;
912
broadcast: { emit(name: string, ...args: unknown[]): void };

0 commit comments

Comments
 (0)