@tpzdsp/next-toolkit 1.14.3 → 1.15.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/package.json +2 -1
  2. package/src/assets/styles/globals.css +5 -1
  3. package/src/components/ErrorBoundary/ErrorBoundary.stories.tsx +1 -1
  4. package/src/components/ErrorBoundary/ErrorBoundary.test.tsx +1 -1
  5. package/src/components/ErrorBoundary/ErrorBoundary.tsx +1 -1
  6. package/src/components/InfoBox/InfoBox.tsx +2 -2
  7. package/src/components/accordion/Accordion.tsx +29 -14
  8. package/src/components/divider/RuleDivider.test.tsx +4 -4
  9. package/src/components/form/Input.test.tsx +3 -11
  10. package/src/components/form/Input.tsx +2 -2
  11. package/src/components/form/TextArea.test.tsx +3 -5
  12. package/src/components/form/TextArea.tsx +2 -2
  13. package/src/components/layout/header/Header.stories.tsx +3 -3
  14. package/src/components/layout/header/Header.test.tsx +3 -3
  15. package/src/components/layout/header/HeaderNavClient.test.tsx +3 -3
  16. package/src/components/select/Select.stories.tsx +5 -5
  17. package/src/components/select/Select.test.tsx +2 -2
  18. package/src/components/select/Select.tsx +3 -4
  19. package/src/components/select/SelectSkeleton.test.tsx +1 -2
  20. package/src/components/select/SelectSkeleton.tsx +3 -3
  21. package/src/components/select/common.ts +2 -3
  22. package/src/http/constants.ts +1 -0
  23. package/src/http/logger.test.ts +346 -0
  24. package/src/http/logger.ts +412 -76
  25. package/src/map/MapComponent.tsx +31 -7
  26. package/src/map/Popup.tsx +6 -3
  27. package/src/map/useKeyboardDrawing.ts +8 -4
  28. package/src/utils/constants.ts +8 -0
  29. package/src/utils/utils.ts +4 -4
  30. package/src/components/theme/ThemeProvider.tsx +0 -30
@@ -2,60 +2,376 @@ import type { BetterFetchPlugin, RequestContext } from '@better-fetch/fetch';
2
2
 
3
3
  import { Header } from './constants';
4
4
 
5
- // Tries to print the request/response body as a string, and clamps the length it it's too long.
6
- const preview = (data: unknown, maxLength: number): string | null => {
5
+ const MAX_PREVIEW_LENGTH = 150;
6
+
7
+ // Tries to print data as a string.
8
+ const stringify = (data: unknown): string | null => {
7
9
  if (data == null) {
8
10
  return null;
9
11
  }
10
12
 
11
13
  try {
12
- let str = typeof data === 'string' ? data : JSON.stringify(data);
13
-
14
- if (str.length > maxLength) {
15
- str = str.slice(0, maxLength) + '…';
16
- }
17
-
18
- return str;
14
+ return typeof data === 'string' ? data : JSON.stringify(data);
19
15
  } catch {
20
16
  return '[unserializable]';
21
17
  }
22
18
  };
23
19
 
20
+ // Tries to print the request/response body as a string, and clamps the length if it's too long.
21
+ const preview = (data: unknown): string | null => {
22
+ const str = stringify(data);
23
+
24
+ if (str == null) {
25
+ return null;
26
+ }
27
+
28
+ if (str.length > MAX_PREVIEW_LENGTH) {
29
+ return str.slice(0, MAX_PREVIEW_LENGTH) + '…';
30
+ }
31
+
32
+ return str;
33
+ };
34
+
24
35
  type RequestMeta = {
25
36
  requestId: string;
26
37
  };
38
+
39
+ type RequestLoggerMessageExtras = {
40
+ loggerOverrides?: {
41
+ requestFormat?: (args: { request: MetaContext }) => string;
42
+
43
+ successFormat?: (args: {
44
+ request: MetaContext;
45
+ response: Response;
46
+ data: unknown;
47
+ durationMs: number;
48
+ }) => string;
49
+
50
+ errorFormat?: (args: {
51
+ request: MetaContext;
52
+ response?: Response;
53
+ error: unknown;
54
+ durationMs: number;
55
+ }) => string;
56
+
57
+ retryFormat?: (args: { request: MetaContext; attempt: number }) => string;
58
+ };
59
+ };
60
+
27
61
  type MetaContext = RequestContext & {
28
62
  meta: RequestMeta;
29
- };
63
+ } & RequestLoggerMessageExtras;
30
64
 
31
65
  const UNKNOWN_ID = '???';
32
66
 
33
- /**
34
- * Config options for the logger.
35
- */
67
+ type RequestResponseToggle = {
68
+ request?: boolean;
69
+ response?: boolean;
70
+ };
71
+
36
72
  type RequestLoggerOptions = {
37
73
  /**
38
- * Enables/Disabled the logger. Can be a function/async function to toggle the logger dynamically
74
+ * Enables/disables the logger. Can be a function/async function to toggle dynamically.
39
75
  */
40
76
  enabled?: boolean | (() => Promise<boolean> | boolean);
77
+
41
78
  /**
42
- * Controls how much of the request and response body is shown in the logs.
79
+ * Whether bodies should be truncated to `maxPreviewLength`.
80
+ * If false, the full body is printed and is placed on the following line.
43
81
  */
44
- maxPreviewLength?: number;
82
+ truncateBody?: boolean;
83
+
84
+ /**
85
+ * Built-in output toggles.
86
+ */
87
+ showMethod?: boolean;
88
+ showUrl?: boolean;
89
+ showStatus?: boolean;
90
+ showDuration?: boolean;
91
+ showAcceptHeader?: boolean;
92
+ showContentType?: boolean;
93
+ showRetryAttempt?: boolean;
94
+ showBody?: RequestResponseToggle;
95
+ showHeaders?: RequestResponseToggle;
96
+ };
97
+
98
+ const defaultOptions = {
99
+ truncateBody: true,
100
+ showMethod: true,
101
+ showUrl: true,
102
+ showStatus: true,
103
+ showDuration: true,
104
+ showAcceptHeader: true,
105
+ showContentType: true,
106
+ showRetryAttempt: true,
107
+ showBody: {
108
+ request: true,
109
+ response: true,
110
+ },
111
+ showHeaders: {
112
+ request: false,
113
+ response: false,
114
+ },
115
+ } as const;
116
+
117
+ const getOptionOrDefault = <K extends keyof RequestLoggerOptions>(
118
+ options: RequestLoggerOptions | undefined,
119
+ key: K,
120
+ ): NonNullable<RequestLoggerOptions[K]> => {
121
+ return options?.[key] ?? (defaultOptions as Required<RequestLoggerOptions>)[key];
122
+ };
123
+
124
+ const formatHeaders = (headers: Headers): string => {
125
+ const entries = Array.from(headers.entries());
126
+
127
+ if (entries.length === 0) {
128
+ return 'N/A';
129
+ }
130
+
131
+ return entries.map(([key, value]) => `${key}: ${value}`).join(', ');
132
+ };
133
+
134
+ const joinLines = (lines: (string | null | undefined)[]): string =>
135
+ lines.filter((line): line is string => Boolean(line)).join('\n');
136
+
137
+ const formatPrimaryLine = ({
138
+ icon,
139
+ request,
140
+ id,
141
+ showMethod,
142
+ showUrl,
143
+ suffix,
144
+ }: {
145
+ icon: string;
146
+ request: MetaContext;
147
+ id: string;
148
+ showMethod: boolean;
149
+ showUrl: boolean;
150
+ suffix?: string;
151
+ }): string => {
152
+ const parts = [
153
+ icon,
154
+ showMethod ? `[${request.method.toUpperCase()}]` : null,
155
+ `(${id})`,
156
+ showUrl ? `${request.url ?? ''}` : null,
157
+ suffix ?? null,
158
+ ].filter(Boolean);
159
+
160
+ return parts.join(' ');
161
+ };
162
+
163
+ const formatStatusDurationLine = ({
164
+ status,
165
+ durationMs,
166
+ showStatus,
167
+ showDuration,
168
+ }: {
169
+ status?: number | string;
170
+ durationMs: number;
171
+ showStatus: boolean;
172
+ showDuration: boolean;
173
+ }): string | null => {
174
+ const parts = [
175
+ showStatus ? `${status ?? 'unknown'}` : null,
176
+ showDuration ? `${durationMs.toFixed(1)}ms` : null,
177
+ ].filter(Boolean);
178
+
179
+ return parts.length > 0 ? `\t↳ ${parts.join(' | ')}` : null;
180
+ };
181
+
182
+ const formatBodyLine = ({
183
+ label,
184
+ body,
185
+ truncateBody,
186
+ }: {
187
+ label: string;
188
+ body: unknown;
189
+ truncateBody: boolean;
190
+ }): string | null => {
191
+ const rendered = truncateBody ? preview(body) : stringify(body);
192
+
193
+ if (!rendered) {
194
+ return null;
195
+ }
196
+
197
+ return truncateBody ? `\t↳ ${label}: ${rendered}` : `\t↳ ${label}:\n${rendered}`;
198
+ };
199
+
200
+ const getDurationMs = (timings: Map<string, number>, id: string): number =>
201
+ performance.now() - (timings.get(id) ?? 0);
202
+
203
+ const defaultRequestFormatter = ({
204
+ request,
205
+ options,
206
+ }: {
207
+ request: MetaContext;
208
+ options?: RequestLoggerOptions;
209
+ }): string => {
210
+ const id = request.meta?.requestId ?? UNKNOWN_ID;
211
+
212
+ const showMethod = getOptionOrDefault(options, 'showMethod');
213
+ const showUrl = getOptionOrDefault(options, 'showUrl');
214
+ const showAcceptHeader = getOptionOrDefault(options, 'showAcceptHeader');
215
+ const showRequestContentType = getOptionOrDefault(options, 'showContentType');
216
+ const showRequestHeaders = getOptionOrDefault(options, 'showHeaders').request;
217
+ const showRequestBody = getOptionOrDefault(options, 'showBody').request;
218
+ const truncateBody = getOptionOrDefault(options, 'truncateBody');
219
+
220
+ return joinLines([
221
+ formatPrimaryLine({
222
+ icon: '🚀',
223
+ request,
224
+ id,
225
+ showMethod,
226
+ showUrl,
227
+ }),
228
+ showRequestContentType
229
+ ? `\t↳ Req. Content-Type: ${request.headers.get(Header.ContentType) ?? 'N/A'}`
230
+ : null,
231
+ showAcceptHeader ? `\t↳ Req. Accept: ${request.headers.get(Header.Accept) ?? 'N/A'}` : null,
232
+ showRequestHeaders ? `\t↳ Req. Headers: ${formatHeaders(request.headers)}` : null,
233
+ showRequestBody
234
+ ? formatBodyLine({
235
+ label: 'Req. Body',
236
+ body: request.body,
237
+ truncateBody,
238
+ })
239
+ : null,
240
+ ]);
241
+ };
242
+
243
+ const defaultSuccessFormatter = ({
244
+ request,
245
+ response,
246
+ data,
247
+ durationMs,
248
+ options,
249
+ }: {
250
+ request: MetaContext;
251
+ response: Response;
252
+ data: unknown;
253
+ durationMs: number;
254
+ options?: RequestLoggerOptions;
255
+ }): string => {
256
+ const id = request.meta?.requestId ?? UNKNOWN_ID;
257
+
258
+ const showMethod = getOptionOrDefault(options, 'showMethod');
259
+ const showUrl = getOptionOrDefault(options, 'showUrl');
260
+ const showStatus = getOptionOrDefault(options, 'showStatus');
261
+ const showDuration = getOptionOrDefault(options, 'showDuration');
262
+ const showResponseContentType = getOptionOrDefault(options, 'showContentType');
263
+ const showResponseHeaders = getOptionOrDefault(options, 'showHeaders').response;
264
+ const showResponseBody = getOptionOrDefault(options, 'showBody').response;
265
+ const truncateBody = getOptionOrDefault(options, 'truncateBody');
266
+
267
+ return joinLines([
268
+ formatPrimaryLine({
269
+ icon: '✅',
270
+ request,
271
+ id,
272
+ showMethod,
273
+ showUrl,
274
+ }),
275
+ formatStatusDurationLine({
276
+ status: response.status,
277
+ durationMs,
278
+ showStatus,
279
+ showDuration,
280
+ }),
281
+ showResponseContentType
282
+ ? `\t↳ Res. Content-Type: ${response.headers.get(Header.ContentType) ?? 'N/A'}`
283
+ : null,
284
+ showResponseHeaders ? `\t↳ Res. Headers: ${formatHeaders(response.headers)}` : null,
285
+ showResponseBody
286
+ ? formatBodyLine({
287
+ label: 'Res. Body',
288
+ body: data,
289
+ truncateBody,
290
+ })
291
+ : null,
292
+ ]);
293
+ };
294
+
295
+ const defaultRetryFormatter = ({
296
+ request,
297
+ attempt,
298
+ options,
299
+ }: {
300
+ request: MetaContext;
301
+ attempt: number;
302
+ options?: RequestLoggerOptions;
303
+ }): string => {
304
+ const id = request.meta?.requestId ?? UNKNOWN_ID;
305
+
306
+ const showMethod = getOptionOrDefault(options, 'showMethod');
307
+ const showUrl = getOptionOrDefault(options, 'showUrl');
308
+ const showRetryAttempt = getOptionOrDefault(options, 'showRetryAttempt');
309
+
310
+ return joinLines([
311
+ formatPrimaryLine({
312
+ icon: '🔁',
313
+ request,
314
+ id,
315
+ showMethod,
316
+ showUrl,
317
+ suffix: 'Retrying...',
318
+ }),
319
+ showRetryAttempt ? `\t↳ Attempt: ${attempt}` : null,
320
+ ]);
321
+ };
322
+
323
+ const defaultErrorFormatter = ({
324
+ request,
325
+ response,
326
+ error,
327
+ durationMs,
328
+ options,
329
+ }: {
330
+ request: MetaContext;
331
+ response?: Response;
332
+ error: unknown;
333
+ durationMs: number;
334
+ options?: RequestLoggerOptions;
335
+ }): string => {
336
+ const id = request.meta?.requestId ?? UNKNOWN_ID;
337
+
338
+ const showMethod = getOptionOrDefault(options, 'showMethod');
339
+ const showUrl = getOptionOrDefault(options, 'showUrl');
340
+ const showStatus = getOptionOrDefault(options, 'showStatus');
341
+ const showDuration = getOptionOrDefault(options, 'showDuration');
342
+ const showResponseHeaders = getOptionOrDefault(options, 'showHeaders').response;
343
+ const truncateBody = getOptionOrDefault(options, 'truncateBody');
344
+
345
+ return joinLines([
346
+ formatPrimaryLine({
347
+ icon: '❌',
348
+ request,
349
+ id,
350
+ showMethod,
351
+ showUrl,
352
+ }),
353
+ formatStatusDurationLine({
354
+ status: response?.status ?? 'unknown',
355
+ durationMs,
356
+ showStatus,
357
+ showDuration,
358
+ }),
359
+ showResponseHeaders && response ? `\t↳ Res. Headers: ${formatHeaders(response.headers)}` : null,
360
+ truncateBody
361
+ ? `\t↳ Error: ${preview(error)}`
362
+ : `\t↳ Error:\n${stringify(error) ?? '[unserializable]'}`,
363
+ ]);
45
364
  };
46
365
 
47
366
  /**
48
- * A logger for the fetch functions that prints detailed information on each request and respective response.
49
- * Each request is given a unique ID which is paired with a response to make finding matching request/response pairs easier.
50
- *
51
- * It currently, prints the method, url, `Accept` and `Content-Type` headers, and the body of the request, and it prints the
52
- * `Content-Type` header, status code, response body and duration of the response, for both successful and failed responses.
367
+ * A logger for fetch functions that prints detailed information for each request and response.
368
+ * Each request is given a unique ID which is paired with its response.
53
369
  */
54
- export const requestLogger = (options?: RequestLoggerOptions): BetterFetchPlugin => {
370
+ export const requestLogger = (
371
+ options?: RequestLoggerOptions,
372
+ ): BetterFetchPlugin<RequestLoggerMessageExtras> => {
55
373
  const enabled =
56
- typeof options?.enabled !== 'function' ? () => options?.enabled ?? true : options?.enabled;
57
-
58
- const maxPreview = options?.maxPreviewLength ?? 150;
374
+ typeof options?.enabled !== 'function' ? () => options?.enabled ?? true : options.enabled;
59
375
 
60
376
  const timings = new Map<string, number>();
61
377
 
@@ -64,99 +380,119 @@ export const requestLogger = (options?: RequestLoggerOptions): BetterFetchPlugin
64
380
  return {
65
381
  id: 'logger',
66
382
  name: 'Logger',
67
- version: '0.1.0',
383
+ version: '0.3.0',
68
384
  hooks: {
69
385
  hookOptions: {
70
386
  cloneResponse: true,
71
387
  },
72
388
 
73
389
  onRequest: async (context) => {
74
- if (!(await enabled?.())) {
390
+ if (!(await enabled())) {
75
391
  return;
76
392
  }
77
393
 
78
- const metaContext = context as MetaContext;
394
+ const request = context as MetaContext;
79
395
 
80
- metaContext.meta = metaContext.meta ?? {};
396
+ request.meta = request.meta ?? ({} as RequestMeta);
397
+ request.loggerOverrides = request.loggerOverrides ?? {};
81
398
 
82
399
  const id = genId();
83
400
 
84
- metaContext.meta.requestId = id;
85
- context.headers.set(Header.XRequestId, id);
401
+ request.meta.requestId = id;
402
+ request.headers.set(Header.XRequestId, id);
86
403
 
87
404
  timings.set(id, performance.now());
88
405
 
89
- const { url, method, body, headers } = context;
90
-
91
- const bodyPreview = body ? preview(body, maxPreview) : '';
92
- const contentType = headers.get(Header.ContentType) ?? 'N/A';
93
- const accept = headers.get(Header.Accept) ?? 'N/A';
94
-
95
406
  const message =
96
- `🚀 [${method}] (${id}) ${url}\n` +
97
- `\t↳ Content-Type: ${contentType}\n` +
98
- `\t↳ Accept: ${accept}` +
99
- (bodyPreview ? `\n\t↳ Body: ${bodyPreview}` : '');
407
+ request.loggerOverrides.requestFormat?.({ request }) ??
408
+ defaultRequestFormatter({
409
+ request,
410
+ options,
411
+ });
100
412
 
101
- console.log(message);
413
+ console.log(`${message}\n`);
102
414
  },
103
415
 
104
416
  onSuccess: async (context) => {
105
- if (!(await enabled?.())) {
417
+ if (!(await enabled())) {
106
418
  return;
107
419
  }
108
420
 
109
- const { response, data, request } = context;
421
+ const request = context.request as MetaContext;
422
+ const { response, data } = context;
110
423
 
111
- const id = (request as MetaContext).meta.requestId ?? UNKNOWN_ID;
112
-
113
- const status = response.status;
114
- const bodyPreview = preview(data, maxPreview);
115
- const contentType = response.headers.get(Header.ContentType) ?? 'N/A';
116
- const duration = (performance.now() - (timings.get(id) ?? 0)).toFixed(1);
424
+ const id = request.meta?.requestId ?? UNKNOWN_ID;
425
+ const durationMs = getDurationMs(timings, id);
117
426
 
118
427
  const message =
119
- `✅ [${request.method}] (${id}) ${request.url}\n` +
120
- `\t↳ Status: ${status} (${duration}ms)\n` +
121
- `\t↳ Respone Content-Type: ${contentType}\n` +
122
- `\t↳ Response: ${bodyPreview}`;
123
-
124
- console.log(message);
428
+ request.loggerOverrides?.successFormat?.({
429
+ request,
430
+ response,
431
+ data,
432
+ durationMs,
433
+ }) ??
434
+ defaultSuccessFormatter({
435
+ request,
436
+ response,
437
+ data,
438
+ durationMs,
439
+ options,
440
+ });
441
+
442
+ console.log(`${message}\n`);
443
+ timings.delete(id);
125
444
  },
126
445
 
127
- onRetry: async (ctx) => {
128
- if (!(await enabled?.())) {
446
+ onRetry: async (context) => {
447
+ if (!(await enabled())) {
129
448
  return;
130
449
  }
131
450
 
132
- const id = (ctx.request as MetaContext).meta.requestId ?? UNKNOWN_ID;
451
+ const request = context.request as MetaContext;
452
+ const attempt = (request.retryAttempt ?? 0) + 1;
133
453
 
134
- console.warn(
135
- `🔁 [${ctx.request.method}] (${id}) Retrying ${ctx.request.url}... Attempt: ${
136
- (ctx.request.retryAttempt ?? 0) + 1
137
- }`,
138
- );
454
+ const message =
455
+ request.loggerOverrides?.retryFormat?.({
456
+ request,
457
+ attempt,
458
+ }) ??
459
+ defaultRetryFormatter({
460
+ request,
461
+ attempt,
462
+ options,
463
+ });
464
+
465
+ console.warn(`${message}\n`);
139
466
  },
140
467
 
141
468
  onError: async (context) => {
142
- if (!(await enabled?.())) {
469
+ if (!(await enabled())) {
143
470
  return;
144
471
  }
145
472
 
146
- const { response, error, request } = context;
147
-
148
- const id = (request as MetaContext).meta.requestId ?? UNKNOWN_ID;
473
+ const request = context.request as MetaContext;
474
+ const { response, error } = context;
149
475
 
150
- const status = response?.status ?? 'unknown';
151
- const errorPreview = preview(error, maxPreview);
152
- const duration = (performance.now() - (timings.get(id) ?? 0)).toFixed(1);
476
+ const id = request.meta?.requestId ?? UNKNOWN_ID;
477
+ const durationMs = getDurationMs(timings, id);
153
478
 
154
479
  const message =
155
- `❌ [${request.method}] (${id}) ${request.url}\n` +
156
- `\t↳ Status: ${status} (${duration}ms)\n` +
157
- `\t↳ Error: ${errorPreview}`;
158
-
159
- console.error(message);
480
+ request.loggerOverrides?.errorFormat?.({
481
+ request,
482
+ response,
483
+ error,
484
+ durationMs,
485
+ }) ??
486
+ defaultErrorFormatter({
487
+ request,
488
+ response,
489
+ error,
490
+ durationMs,
491
+ options,
492
+ });
493
+
494
+ console.error(`${message}\n`);
495
+ timings.delete(id);
160
496
  },
161
497
  },
162
498
  };
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { memo, useEffect, useRef, useState } from 'react';
4
4
 
5
- import { Map, Overlay, View } from 'ol';
5
+ import { Feature, Map, Overlay, View } from 'ol';
6
6
  import { Attribution, ScaleLine, Zoom } from 'ol/control';
7
7
  import { fromLonLat } from 'ol/proj';
8
8
 
@@ -11,7 +11,7 @@ import { FullScreenControl } from './FullScreenControl';
11
11
  import { LayerSwitcherControl } from './LayerSwitcherControl';
12
12
  import { useMap } from './MapContext';
13
13
  import { Popup } from './Popup';
14
- import { getPopupPositionClass } from './utils';
14
+ import { getPopupPositionClass, LAYER_NAMES } from './utils';
15
15
  import type { PopupDirection } from '../types/map';
16
16
 
17
17
  export type MapComponentProps = {
@@ -44,7 +44,7 @@ const arrowStyles: Record<PopupDirection, string> = {
44
44
  * @return {*}
45
45
  */
46
46
  const MapComponentBase = ({ osMapsApiKey, basePath }: MapComponentProps) => {
47
- const [popupFeatures, setPopupFeatures] = useState([]);
47
+ const [popupFeatures, setPopupFeatures] = useState<Feature[]>([]);
48
48
  const [popupCoordinate, setPopupCoordinate] = useState<number[] | null>(null);
49
49
  const [popupPositionClass, setPopupPositionClass] = useState<PopupDirection>('bottom-right');
50
50
 
@@ -117,17 +117,41 @@ const MapComponentBase = ({ osMapsApiKey, basePath }: MapComponentProps) => {
117
117
  return;
118
118
  }
119
119
 
120
- newMap.forEachFeatureAtPixel(event.pixel, (feature) => {
121
- const features = feature.get('features');
120
+ newMap.forEachFeatureAtPixel(event.pixel, (feature, layer) => {
121
+ const clusterFeatures = feature.get('features');
122
+ const layerName = (layer as { get(k: string): unknown } | null)?.get('name');
122
123
 
123
- if (features?.length > 0) {
124
+ if (clusterFeatures?.length > 0) {
124
125
  const coordinate = event.coordinate;
125
126
  const direction = getPopupPositionClass(coordinate, newMap);
126
127
 
127
- setPopupFeatures(features);
128
+ setPopupFeatures(clusterFeatures);
128
129
  setPopupCoordinate(coordinate);
129
130
  setPopupPositionClass(direction);
130
131
  overlay.setPosition(event.coordinate);
132
+
133
+ return true; // stop iteration
134
+ }
135
+
136
+ // Direct feature (e.g. polygon) — only show popup for the sampling points
137
+ // layer, not boundary or AOI layers which may also have name properties.
138
+ if (layerName !== LAYER_NAMES.SamplingPoints) {
139
+ return;
140
+ }
141
+
142
+ const name = feature.get('name');
143
+ const notation = feature.get('notation');
144
+
145
+ if (name || notation) {
146
+ const coordinate = event.coordinate;
147
+ const direction = getPopupPositionClass(coordinate, newMap);
148
+
149
+ setPopupFeatures([feature as Feature]);
150
+ setPopupCoordinate(coordinate);
151
+ setPopupPositionClass(direction);
152
+ overlay.setPosition(event.coordinate);
153
+
154
+ return true; // stop iteration
131
155
  }
132
156
  });
133
157
  });
package/src/map/Popup.tsx CHANGED
@@ -41,13 +41,16 @@ export const Popup = ({
41
41
  rounded-lg divide-y divide-gray-300"
42
42
  >
43
43
  {features.map((feature) => {
44
- const id = feature.get('id');
45
44
  const name = feature.get('name');
46
45
  const notation = feature.get('notation');
47
- const url = `${baseUrl}${notation}`;
46
+ const libraryNotation = feature.get('libraryNotation');
47
+ const identifier = notation ?? name;
48
+ const url = libraryNotation
49
+ ? `${baseUrl}${libraryNotation}/${identifier}`
50
+ : `${baseUrl}${identifier}`;
48
51
 
49
52
  return (
50
- <div key={id}>
53
+ <div key={identifier}>
51
54
  <strong>
52
55
  <ExternalLink
53
56
  href={url}
@@ -70,19 +70,23 @@ export const useKeyboardDrawing = ({
70
70
  }
71
71
 
72
72
  switch (event.key) {
73
- case KeyboardKeys.ArrowUp:
73
+ case KeyboardKeys.W:
74
+ case KeyboardKeys.I:
74
75
  moveCursor(0, -1);
75
76
  event.preventDefault();
76
77
  break;
77
- case KeyboardKeys.ArrowDown:
78
+ case KeyboardKeys.S:
79
+ case KeyboardKeys.K:
78
80
  moveCursor(0, 1);
79
81
  event.preventDefault();
80
82
  break;
81
- case KeyboardKeys.ArrowLeft:
83
+ case KeyboardKeys.A:
84
+ case KeyboardKeys.J:
82
85
  moveCursor(-1, 0);
83
86
  event.preventDefault();
84
87
  break;
85
- case KeyboardKeys.ArrowRight:
88
+ case KeyboardKeys.D:
89
+ case KeyboardKeys.L:
86
90
  moveCursor(1, 0);
87
91
  event.preventDefault();
88
92
  break;