x4js 2.3.28 → 2.3.29

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 (32) hide show
  1. package/aicontext.md +380 -19
  2. package/package.json +7 -3
  3. package/src/components/bargraph/bargraph.module.scss +44 -0
  4. package/src/components/bargraph/bargraph.ts +362 -0
  5. package/src/components/base.scss +71 -1
  6. package/src/components/calendar/calendar.module.scss +6 -5
  7. package/src/components/calendar/calendar.ts +14 -8
  8. package/src/components/canvas/canvas.ts +5 -4
  9. package/src/components/carousel/carousel.module.scss +165 -0
  10. package/src/components/carousel/carousel.ts +423 -0
  11. package/src/components/chart/chart.module.scss +8 -0
  12. package/src/components/chart/chart.ts +170 -12
  13. package/src/components/combobox/combobox.module.scss +2 -1
  14. package/src/components/components.ts +3 -0
  15. package/src/components/gantt/gantt.module.scss +462 -0
  16. package/src/components/gantt/gantt.ts +1254 -0
  17. package/src/components/knob/knob.module.scss +113 -0
  18. package/src/components/knob/knob.ts +355 -0
  19. package/src/components/mapview/mapview.module.scss +177 -0
  20. package/src/components/mapview/mapview.ts +764 -0
  21. package/src/components/planner/planner.module.scss +688 -0
  22. package/src/components/planner/planner.ts +1792 -0
  23. package/src/components/radio/radio.module.scss +4 -1
  24. package/src/components/slider/slider.module.scss +22 -20
  25. package/src/components/switch/switch.module.scss +68 -44
  26. package/src/components/switch/switch.ts +6 -3
  27. package/src/components/tag/tag.module.scss +4 -4
  28. package/src/components/textedit/textedit.module.scss +5 -2
  29. package/src/components/themes.scss +19 -6
  30. package/src/core/core_i18n.ts +65 -0
  31. package/src/core/core_tools.ts +44 -0
  32. package/src/x4doc.ts +3 -0
@@ -0,0 +1,764 @@
1
+ /**
2
+ * ___ ___ __
3
+ * \ \/ / / _
4
+ * \ / /_| |_
5
+ * / \____ _|
6
+ * /__/\__\ |_|
7
+ *
8
+ * @file mapview.ts
9
+ * @author Etienne Cochard
10
+ *
11
+ * @copyright (c) 2026 R-libre ingenierie
12
+ *
13
+ * Use of this source code is governed by an MIT-style license
14
+ * that can be found in the LICENSE file or at https://opensource.org/licenses/MIT.
15
+ **/
16
+
17
+ import { EventCallback } from '../../core/core_events';
18
+ import { Component, ComponentEvent, ComponentEvents, ComponentProps } from '../../core/component';
19
+ import { SvgBuilder, SvgComponent } from '../../core/core_svg';
20
+ import { clamp, UnsafeHtml, unsafeHtml } from '../../core/core_tools';
21
+
22
+ import { VBox } from '../boxes/boxes';
23
+ import { Button } from '../button/button';
24
+ import { Icon } from '../icon/icon';
25
+ import { SimpleText } from '../label/label';
26
+
27
+ import './mapview.module.scss';
28
+
29
+ export interface LatLng {
30
+ lat: number;
31
+ lng: number;
32
+ }
33
+
34
+ export interface MapMarker extends LatLng {
35
+ title?: string; // its tooltip (shown as html: escape a text from a user)
36
+ icon?: string; // a pin by default
37
+ data?: any;
38
+ }
39
+
40
+ // a zone on the map: its color is the one of the palette of Chart (--chart-color-1..8), in turn,
41
+ // or its own
42
+
43
+ interface MapShapeBase {
44
+ title?: string; // its tooltip (shown as html: escape a text from a user)
45
+ color?: string;
46
+ width?: number; // its line, in pixels: 4 for a line, 2 for the border of a polygon or a circle by default
47
+ data?: any;
48
+ }
49
+
50
+ export interface MapPolygon extends MapShapeBase {
51
+ type: "polygon";
52
+ points: LatLng[];
53
+ }
54
+
55
+ export interface MapCircle extends MapShapeBase {
56
+ type: "circle";
57
+ center: LatLng;
58
+ radius: number; // in meters
59
+ }
60
+
61
+ export interface MapLine extends MapShapeBase {
62
+ type: "line";
63
+ points: LatLng[];
64
+ }
65
+
66
+ export type MapShape = MapPolygon | MapCircle | MapLine;
67
+
68
+ export interface EvMapMove extends ComponentEvent {
69
+ readonly center: LatLng;
70
+ readonly zoom: number;
71
+ }
72
+
73
+ export interface EvMapClick extends ComponentEvent {
74
+ readonly latlng: LatLng;
75
+ }
76
+
77
+ export interface EvMarkerClick extends ComponentEvent {
78
+ readonly marker: MapMarker;
79
+ }
80
+
81
+ export interface EvShapeClick extends ComponentEvent {
82
+ readonly shape: MapShape;
83
+ }
84
+
85
+ export interface MapViewEvents extends ComponentEvents {
86
+ move: EvMapMove;
87
+ click: EvMapClick;
88
+ markerClick: EvMarkerClick;
89
+ shapeClick: EvShapeClick;
90
+ }
91
+
92
+ export interface MapViewProps extends Omit<ComponentProps,"content"> {
93
+ center?: LatLng; // 0,0 by default
94
+ zoom?: number; // 2 by default
95
+ minZoom?: number; // 1 by default
96
+ maxZoom?: number; // 19 by default
97
+ /** the url of a tile: {z} {x} {y}; openstreetmap by default */
98
+ tiles?: string;
99
+ /** the credit of the tiles, always shown; the one of openstreetmap by default */
100
+ attribution?: string | UnsafeHtml;
101
+ markers?: MapMarker[];
102
+ /** zones, under the markers */
103
+ shapes?: MapShape[];
104
+ /** the user moved or zoomed the map (not setCenter, setZoom, fitBounds) */
105
+ move?: EventCallback<EvMapMove>;
106
+ /** a click on the map, not on a marker nor a zone: where */
107
+ click?: EventCallback<EvMapClick>;
108
+ markerClick?: EventCallback<EvMarkerClick>;
109
+ shapeClick?: EventCallback<EvShapeClick>;
110
+ }
111
+
112
+ const TILE = 256;
113
+ const NUM_COLORS = 8;
114
+ const EQUATOR = 40075016.686; // meters: the world at the equator, TILE pixels at the zoom 0
115
+ const MAX_LAT = 85.0511287798; // web mercator: the world is a square
116
+ const OSM_TILES = "https://tile.openstreetmap.org/{z}/{x}/{y}.png";
117
+ const OSM_CREDIT = unsafeHtml( `© <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">OpenStreetMap</a> contributors` );
118
+
119
+ const icon_pin = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 32"><path d="M12 0C5.4 0 0 5.4 0 12c0 9 12 20 12 20s12-11 12-20C24 5.4 18.6 0 12 0z" fill="currentColor"/><circle cx="12" cy="12" r="4.5" fill="#fff"/></svg>`;
120
+
121
+ // web mercator: a place → its pixel in the world at the zoom z, and back
122
+
123
+ function project( ll: LatLng, z: number ) {
124
+ const ws = TILE * 2**z;
125
+ const s = Math.sin( clamp( ll.lat, -MAX_LAT, MAX_LAT ) * Math.PI / 180 );
126
+
127
+ return {
128
+ x: ( ll.lng + 180 ) / 360 * ws,
129
+ y: ( 0.5 - Math.log( (1+s) / (1-s) ) / ( 4*Math.PI ) ) * ws,
130
+ };
131
+ }
132
+
133
+ function unproject( x: number, y: number, z: number ): LatLng {
134
+ const ws = TILE * 2**z;
135
+ const n = Math.PI - 2*Math.PI * y / ws;
136
+
137
+ return {
138
+ lat: 180 / Math.PI * Math.atan( Math.sinh( n ) ),
139
+ lng: x / ws * 360 - 180,
140
+ };
141
+ }
142
+
143
+
144
+ /**
145
+ * Not in `x4js`: import it from `"x4js/mapview"`.
146
+ *
147
+ * a map of tiles (openstreetmap by default): the pointer moves it, the wheel, a double click
148
+ * and the buttons zoom it, markers are put on places.
149
+ *
150
+ * The tiles of openstreetmap are for a light use: an application with many users takes
151
+ * its tiles from a provider (props tiles and attribution).
152
+ *
153
+ * @cssvar
154
+ * ```
155
+ * --mapview-background
156
+ * --mapview-marker
157
+ * --mapview-tile-filter
158
+ * --mapview-credit-background
159
+ * ```
160
+ */
161
+
162
+ export class MapView extends Component<MapViewProps,MapViewEvents> {
163
+ static classPrefix = "x4";
164
+
165
+ private _zoom: number;
166
+ private _cx: number; // the center, in pixels of the world at _zoom
167
+ private _cy: number;
168
+ private _markers: MapMarker[];
169
+ private _fit: LatLng[] = null; // fitBounds before the map has a size
170
+
171
+ // two layers of tiles: the front one is drawn; the back one, the tiles of the zoom before,
172
+ // grows or shrinks under it while the new tiles come
173
+ private _layers: Component[];
174
+ private _tiles: Component;
175
+ private _zones: SvgComponent;
176
+ private _pins: Component;
177
+ private _shapes: MapShape[];
178
+ private _shown = new Map<string, Component>( ); // tiles of the front layer: "z/x/y"
179
+
180
+ private _pointers = new Map<number, { x: number, y: number }>( );
181
+ private _drag: { x: number, y: number, moved: number } = null;
182
+ private _pinch: { d0: number, m0: { x: number, y: number }, r: number, dx: number, dy: number } = null;
183
+ private _wheel = 0;
184
+
185
+ constructor( props: MapViewProps ) {
186
+ super( props );
187
+
188
+ this.mapPropEvents( props, "move", "click", "markerClick", "shapeClick" );
189
+
190
+ this._zoom = clamp( props.zoom ?? 2, this._minZoom( ), this._maxZoom( ) );
191
+ const c = project( props.center ?? { lat: 0, lng: 0 }, this._zoom );
192
+ this._cx = c.x;
193
+ this._cy = c.y;
194
+ this._markers = props.markers ?? [];
195
+ this._shapes = props.shapes ?? [];
196
+
197
+ this.setContent( [
198
+ this._tiles = new Component( { cls: "tiles front" } ),
199
+ new Component( { cls: "tiles" } ),
200
+ this._zones = new SvgComponent( { cls: "shapes" } ),
201
+ this._pins = new Component( { cls: "markers" } ),
202
+ new VBox( { cls: "zoom", content: [
203
+ new Button( { label: "+", tabindex: false, click: ( ) => this._zoomBy( 1 ) } ),
204
+ new Button( { label: "−", tabindex: false, click: ( ) => this._zoomBy( -1 ) } ),
205
+ ] } ),
206
+ new SimpleText( { cls: "credit", text: props.attribution ?? OSM_CREDIT } ),
207
+ ] );
208
+
209
+ this._layers = this.queryAll( ".tiles" );
210
+
211
+ this.setAttribute( "tabindex", 0 );
212
+ this._buildMarkers( );
213
+
214
+ this.setDOMEvents( {
215
+ created: ( ) => this._render( ),
216
+ resized: ( ) => this._render( ),
217
+ pointerdown: ( ev ) => this._on_pointerdown( ev ),
218
+ pointermove: ( ev ) => this._on_pointermove( ev ),
219
+ pointerup: ( ev ) => this._on_pointerup( ev ),
220
+ pointercancel: ( ev ) => this._on_pointerup( ev ),
221
+ wheel: ( ev ) => this._on_wheel( ev ),
222
+ dblclick: ( ev ) => this._on_dblclick( ev ),
223
+ keydown: ( ev ) => this._on_key( ev ),
224
+ } );
225
+ }
226
+
227
+ private _minZoom( ) { return this.props.minZoom ?? 1; }
228
+ private _maxZoom( ) { return this.props.maxZoom ?? 19; }
229
+
230
+ // :: drawing ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
231
+
232
+ private _render( ) {
233
+ const rc = this.getBoundingRect( );
234
+ if( !rc.width || !rc.height ) {
235
+ return;
236
+ }
237
+
238
+ if( this._fit ) {
239
+ const points = this._fit;
240
+ this._fit = null;
241
+ this.fitBounds( points );
242
+ return;
243
+ }
244
+
245
+ const z = this._zoom;
246
+ const n = 2**z;
247
+ const ws = TILE * n;
248
+
249
+ // east and west, the world goes round; north and south, it stops
250
+ this._cx = ( ( this._cx % ws ) + ws ) % ws;
251
+ this._cy = clamp( this._cy, 0, ws );
252
+
253
+ const left = this._cx - rc.width/2;
254
+ const top = this._cy - rc.height/2;
255
+
256
+ const keep = new Set<string>( );
257
+ const url = this.props.tiles ?? OSM_TILES;
258
+
259
+ for( let ty=Math.floor( top/TILE ); ty<=Math.floor( (top+rc.height)/TILE ); ty++ ) {
260
+ if( ty<0 || ty>=n ) {
261
+ continue;
262
+ }
263
+
264
+ for( let tx=Math.floor( left/TILE ); tx<=Math.floor( (left+rc.width)/TILE ); tx++ ) {
265
+ // tx out of the world: the same tile again, at its place
266
+ const key = `${z}/${tx}/${ty}`;
267
+ keep.add( key );
268
+
269
+ let tile = this._shown.get( key );
270
+ if( !tile ) {
271
+ const wx = ( ( tx % n ) + n ) % n;
272
+ tile = new Component( { tag: "img", cls: "tile", attrs: {
273
+ src: url.replace( "{z}", z+"" ).replace( "{x}", wx+"" ).replace( "{y}", ty+"" ),
274
+ alt: "",
275
+ draggable: "false",
276
+ } } );
277
+
278
+ tile.addDOMEvent( "load", ( ) => tile.addClass( "loaded" ) );
279
+ this._tiles.appendContent( tile );
280
+ this._shown.set( key, tile );
281
+ }
282
+
283
+ tile.setStyle( { left: Math.round( tx*TILE - left ), top: Math.round( ty*TILE - top ) } );
284
+ }
285
+ }
286
+
287
+ // the tiles not seen anymore (an other zoom too)
288
+ for( const [key,tile] of this._shown ) {
289
+ if( !keep.has( key ) ) {
290
+ this._tiles.removeChild( tile );
291
+ this._shown.delete( key );
292
+ }
293
+ }
294
+
295
+ this._drawShapes( left, top, ws );
296
+
297
+ // the markers: the copy of the world nearest to the center
298
+ for( const pin of this._pins.enumChildComponents( false ) ) {
299
+ const m = pin.getInternalData<MapMarker>( "marker" );
300
+ const p = project( m, z );
301
+ const x = p.x + Math.round( ( this._cx - p.x ) / ws ) * ws;
302
+
303
+ pin.setStyle( { left: Math.round( x - left ), top: Math.round( p.y - top ) } );
304
+ }
305
+ }
306
+
307
+ // the zones, drawn again at each move: pixels of the view
308
+ private _drawShapes( left: number, top: number, ws: number ) {
309
+ const z = this._zoom;
310
+ const bld = new SvgBuilder( );
311
+
312
+ // the copy of the world nearest to the center, from the first point
313
+ const view = ( first: LatLng ) => {
314
+ const p = project( first, z );
315
+ const dx = Math.round( ( this._cx - p.x ) / ws ) * ws - left;
316
+ return ( ll: LatLng ) => {
317
+ const q = project( ll, z );
318
+ return { x: q.x + dx, y: q.y - top };
319
+ };
320
+ };
321
+
322
+ this._shapes.forEach( ( shape, i ) => {
323
+ let item: ReturnType<SvgBuilder["circle"]> | ReturnType<SvgBuilder["path"]>;
324
+
325
+ if( shape.type=="circle" ) {
326
+ const c = view( shape.center )( shape.center );
327
+ // the meters of a pixel there
328
+ const mpp = EQUATOR * Math.cos( shape.center.lat * Math.PI / 180 ) / ( TILE * 2**z );
329
+ item = bld.circle( c.x, c.y, shape.radius / mpp );
330
+ }
331
+ else {
332
+ if( !shape.points?.length ) {
333
+ return;
334
+ }
335
+
336
+ const at = view( shape.points[0] );
337
+ const pth = bld.path( );
338
+ shape.points.forEach( ( ll, k ) => {
339
+ const p = at( ll );
340
+ if( k==0 ) pth.moveTo( p.x, p.y );
341
+ else pth.lineTo( p.x, p.y );
342
+ } );
343
+
344
+ if( shape.type=="polygon" ) {
345
+ pth.closePath( );
346
+ }
347
+
348
+ item = pth;
349
+ }
350
+
351
+ item.addClass( `shape ${shape.type} c${ i % NUM_COLORS + 1 }` )
352
+ .setAttr( "data-shape", i+"" );
353
+
354
+ if( shape.color ) {
355
+ item.getDom( ).style.setProperty( "--shape-c", shape.color );
356
+ }
357
+
358
+ // a style: the css of .shape wins over an attribute
359
+ if( shape.width!==undefined ) {
360
+ item.setStyle( "strokeWidth", shape.width );
361
+ }
362
+
363
+ if( shape.title ) {
364
+ item.setAttr( "tooltip", shape.title );
365
+ }
366
+ } );
367
+
368
+ this._zones.setSvg( bld );
369
+ }
370
+
371
+ private _buildMarkers( ) {
372
+ this._pins.setContent( this._markers.map( m => {
373
+ const pin = new Icon( { cls: "marker", iconId: m.icon ?? icon_pin, tooltip: m.title } );
374
+ pin.setInternalData( "marker", m );
375
+ return pin;
376
+ } ) );
377
+ }
378
+
379
+ // :: user ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
380
+
381
+ private _fireMove( ) {
382
+ this.fire( "move", { center: this.getCenter( ), zoom: this._zoom } );
383
+ }
384
+
385
+ // mx, my: the point that stays in place, in the view; its center by default.
386
+ // pinch: the gesture that ends (the front layer has its transform), the zoom may stay the same
387
+ private _zoomAt( z: number, mx?: number, my?: number, pinch?: { dx: number, dy: number } ) {
388
+ z = clamp( z, this._minZoom( ), this._maxZoom( ) );
389
+ if( z==this._zoom && !pinch ) {
390
+ return false;
391
+ }
392
+
393
+ const rc = this.getBoundingRect( );
394
+ mx ??= rc.width/2;
395
+ my ??= rc.height/2;
396
+
397
+ const f = 2**( z - this._zoom );
398
+
399
+ // the tiles before go to the back, and grow (or shrink) to their place at the new zoom
400
+ const back = this._swapLayers( );
401
+ if( pinch ) {
402
+ back.setStyle( { transform: `translate(${pinch.dx}px,${pinch.dy}px) scale(${f})` } );
403
+ }
404
+ else {
405
+ back.setStyle( { transformOrigin: `${mx}px ${my}px`, transform: `scale(${f})` } );
406
+ }
407
+
408
+ this.setTimeout( "zoomanim", 300, ( ) => this._endZoomAnim( ) );
409
+
410
+ const px = this._cx - rc.width/2 + mx;
411
+ const py = this._cy - rc.height/2 + my;
412
+
413
+ this._cx = px*f - mx + rc.width/2;
414
+ this._cy = py*f - my + rc.height/2;
415
+ this._zoom = z;
416
+
417
+ this._render( );
418
+ return true;
419
+ }
420
+
421
+ // the front layer goes to the back (its tiles, its transform), the back one is emptied for the front
422
+ private _swapLayers( ) {
423
+ this._endZoomAnim( );
424
+
425
+ const back = this._tiles;
426
+ const front = this._layers.find( l => l!==back );
427
+
428
+ back.removeClass( "front" );
429
+ back.addClass( "back" );
430
+ front.addClass( "front" );
431
+
432
+ this._tiles = front;
433
+ this._shown = new Map( );
434
+ return back;
435
+ }
436
+
437
+ private _endZoomAnim( ) {
438
+ this.clearTimeout( "zoomanim" );
439
+
440
+ for( const layer of this._layers ) {
441
+ if( layer!==this._tiles ) {
442
+ layer.clearContent( );
443
+ layer.removeClass( "back" );
444
+ layer.setStyle( { transform: "", transformOrigin: "" } );
445
+ }
446
+ }
447
+ }
448
+
449
+ private _zoomBy( dz: number ) {
450
+ if( this._zoomAt( this._zoom + dz ) ) {
451
+ this._fireMove( );
452
+ }
453
+ }
454
+
455
+ // the buttons and the credit are not the map
456
+ private _onMap( ev: Event ) {
457
+ return !Component.closest( ev.target as Element, ".zoom, .credit" );
458
+ }
459
+
460
+ private _on_pointerdown( ev: PointerEvent ) {
461
+ if( ev.button!=0 || !this._onMap( ev ) ) {
462
+ return;
463
+ }
464
+
465
+ ev.preventDefault( );
466
+ this.focus( );
467
+ this.setCapture( ev.pointerId );
468
+
469
+ this._pointers.set( ev.pointerId, { x: ev.clientX, y: ev.clientY } );
470
+
471
+ if( this._pointers.size==1 ) {
472
+ this._endZoomAnim( );
473
+ this._drag = { x: ev.clientX, y: ev.clientY, moved: 0 };
474
+ this.addClass( "dragging" );
475
+ }
476
+ else if( this._pointers.size==2 ) {
477
+ this._startPinch( );
478
+ }
479
+ }
480
+
481
+ private _on_pointermove( ev: PointerEvent ) {
482
+ if( !this._pointers.has( ev.pointerId ) ) {
483
+ return;
484
+ }
485
+
486
+ this._pointers.set( ev.pointerId, { x: ev.clientX, y: ev.clientY } );
487
+
488
+ if( this._pinch ) {
489
+ this._movePinch( );
490
+ return;
491
+ }
492
+
493
+ if( !this._drag ) {
494
+ return;
495
+ }
496
+
497
+ const dx = ev.clientX - this._drag.x;
498
+ const dy = ev.clientY - this._drag.y;
499
+
500
+ this._drag.x = ev.clientX;
501
+ this._drag.y = ev.clientY;
502
+ this._drag.moved += Math.abs( dx ) + Math.abs( dy );
503
+
504
+ this._cx -= dx;
505
+ this._cy -= dy;
506
+ this._render( );
507
+ }
508
+
509
+ // it did not move: a click, on a marker or on the map
510
+ private _on_pointerup( ev: PointerEvent ) {
511
+ if( !this._pointers.delete( ev.pointerId ) ) {
512
+ return;
513
+ }
514
+
515
+ this.releaseCapture( ev.pointerId );
516
+
517
+ if( this._pinch ) {
518
+ // the finger left on the screen does not move the map
519
+ if( this._pointers.size<2 ) {
520
+ this._endPinch( );
521
+ }
522
+ return;
523
+ }
524
+
525
+ if( !this._drag ) {
526
+ return;
527
+ }
528
+
529
+ const moved = this._drag.moved;
530
+ this._drag = null;
531
+ this.removeClass( "dragging" );
532
+
533
+ if( moved>=4 ) {
534
+ this._fireMove( );
535
+ return;
536
+ }
537
+
538
+ if( ev.type!="pointerup" ) {
539
+ return;
540
+ }
541
+
542
+ // a marker is over a zone
543
+ const pin = Component.closest( ev.target as Element, ".marker" );
544
+ const zone = ( ev.target as Element ).closest( "[data-shape]" );
545
+
546
+ if( pin ) {
547
+ this.fire( "markerClick", { marker: pin.getInternalData<MapMarker>( "marker" ) } );
548
+ }
549
+ else if( zone ) {
550
+ this.fire( "shapeClick", { shape: this._shapes[ parseInt( zone.getAttribute( "data-shape" ) ) ] } );
551
+ }
552
+ else {
553
+ const rc = this.getBoundingRect( );
554
+ this.fire( "click", { latlng: this._latlngAt( ev.clientX-rc.left, ev.clientY-rc.top ) } );
555
+ }
556
+ }
557
+
558
+ // :: pinch: two fingers; the front layer follows them, the zoom is set when they leave
559
+
560
+ private _twoPointers( ) {
561
+ const rc = this.getBoundingRect( );
562
+ const [a,b] = [ ...this._pointers.values( ) ];
563
+
564
+ return {
565
+ d: Math.hypot( b.x-a.x, b.y-a.y ) || 1,
566
+ m: { x: (a.x+b.x)/2 - rc.left, y: (a.y+b.y)/2 - rc.top },
567
+ };
568
+ }
569
+
570
+ private _startPinch( ) {
571
+ const { d, m } = this._twoPointers( );
572
+
573
+ this._drag = null;
574
+ this._endZoomAnim( );
575
+ this.removeClass( "dragging" );
576
+ this.addClass( "pinching" );
577
+
578
+ this._pinch = { d0: d, m0: m, r: 1, dx: 0, dy: 0 };
579
+ this._tiles.setStyle( { transformOrigin: `${m.x}px ${m.y}px` } );
580
+ }
581
+
582
+ private _movePinch( ) {
583
+ const { d, m } = this._twoPointers( );
584
+ const p = this._pinch;
585
+
586
+ // not beyond the zooms the map has
587
+ p.r = clamp( d / p.d0, 2**( this._minZoom( ) - this._zoom ), 2**( this._maxZoom( ) - this._zoom ) );
588
+ p.dx = m.x - p.m0.x;
589
+ p.dy = m.y - p.m0.y;
590
+
591
+ this._tiles.setStyle( { transform: `translate(${p.dx}px,${p.dy}px) scale(${p.r})` } );
592
+ }
593
+
594
+ // the nearest zoom: the layer goes from the scale of the fingers to the one of that zoom
595
+ private _endPinch( ) {
596
+ const p = this._pinch;
597
+ this._pinch = null;
598
+ this.removeClass( "pinching" );
599
+
600
+ this._cx -= p.dx;
601
+ this._cy -= p.dy;
602
+ this._zoomAt( this._zoom + Math.round( Math.log2( p.r ) ), p.m0.x+p.dx, p.m0.y+p.dy, p );
603
+ this._fireMove( );
604
+ }
605
+
606
+ // a mouse gives 100 a notch, a touchpad less but often: a zoom each 100
607
+ private _on_wheel( ev: WheelEvent ) {
608
+ if( !this._onMap( ev ) ) {
609
+ return;
610
+ }
611
+
612
+ ev.preventDefault( );
613
+
614
+ this._wheel += ev.deltaY;
615
+ if( Math.abs( this._wheel )<100 ) {
616
+ return;
617
+ }
618
+
619
+ const rc = this.getBoundingRect( );
620
+ const dz = this._wheel<0 ? 1 : -1;
621
+ this._wheel = 0;
622
+
623
+ if( this._zoomAt( this._zoom+dz, ev.clientX-rc.left, ev.clientY-rc.top ) ) {
624
+ this._fireMove( );
625
+ }
626
+ }
627
+
628
+ private _on_dblclick( ev: MouseEvent ) {
629
+ if( !this._onMap( ev ) ) {
630
+ return;
631
+ }
632
+
633
+ const rc = this.getBoundingRect( );
634
+ if( this._zoomAt( this._zoom+1, ev.clientX-rc.left, ev.clientY-rc.top ) ) {
635
+ this._fireMove( );
636
+ }
637
+ }
638
+
639
+ private _on_key( ev: KeyboardEvent ) {
640
+ const STEP = 64;
641
+
642
+ switch( ev.key ) {
643
+ case "ArrowLeft": this._cx -= STEP; break;
644
+ case "ArrowRight": this._cx += STEP; break;
645
+ case "ArrowUp": this._cy -= STEP; break;
646
+ case "ArrowDown": this._cy += STEP; break;
647
+ case "+": this._zoomBy( 1 ); ev.preventDefault( ); return;
648
+ case "-": this._zoomBy( -1 ); ev.preventDefault( ); return;
649
+ default: return;
650
+ }
651
+
652
+ ev.preventDefault( );
653
+ this._render( );
654
+ this._fireMove( );
655
+ }
656
+
657
+ private _latlngAt( mx: number, my: number ) {
658
+ const rc = this.getBoundingRect( );
659
+ const ll = unproject( this._cx - rc.width/2 + mx, this._cy - rc.height/2 + my, this._zoom );
660
+
661
+ // the copies of the world: back to -180..180
662
+ ll.lng = ( ( ll.lng + 540 ) % 360 ) - 180;
663
+ return ll;
664
+ }
665
+
666
+ // :: api :::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
667
+
668
+ getCenter( ): LatLng {
669
+ const ll = unproject( this._cx, this._cy, this._zoom );
670
+ ll.lng = ( ( ll.lng + 540 ) % 360 ) - 180;
671
+ return ll;
672
+ }
673
+
674
+ setCenter( center: LatLng, zoom?: number ) {
675
+ if( zoom!==undefined ) {
676
+ this._zoom = clamp( Math.round( zoom ), this._minZoom( ), this._maxZoom( ) );
677
+ }
678
+
679
+ this._endZoomAnim( );
680
+
681
+ const p = project( center, this._zoom );
682
+ this._cx = p.x;
683
+ this._cy = p.y;
684
+ this._render( );
685
+ }
686
+
687
+ getZoom( ) {
688
+ return this._zoom;
689
+ }
690
+
691
+ setZoom( zoom: number ) {
692
+ this._zoomAt( Math.round( zoom ) );
693
+ }
694
+
695
+ /**
696
+ * the highest zoom where all the points are seen, with a margin
697
+ */
698
+
699
+ fitBounds( points: LatLng[], margin = 32 ) {
700
+ if( !points?.length ) {
701
+ return;
702
+ }
703
+
704
+ const rc = this.getBoundingRect( );
705
+ if( !rc.width || !rc.height ) {
706
+ this._fit = points; // when it has a size
707
+ return;
708
+ }
709
+
710
+ let z = this._maxZoom( );
711
+ for( ; z>this._minZoom( ); z-- ) {
712
+ const pts = points.map( p => project( p, z ) );
713
+ const w = Math.max( ...pts.map( p => p.x ) ) - Math.min( ...pts.map( p => p.x ) );
714
+ const h = Math.max( ...pts.map( p => p.y ) ) - Math.min( ...pts.map( p => p.y ) );
715
+
716
+ if( w<=rc.width-margin*2 && h<=rc.height-margin*2 ) {
717
+ break;
718
+ }
719
+ }
720
+
721
+ this._endZoomAnim( );
722
+
723
+ const pts = points.map( p => project( p, z ) );
724
+ this._zoom = z;
725
+ this._cx = ( Math.max( ...pts.map( p => p.x ) ) + Math.min( ...pts.map( p => p.x ) ) ) / 2;
726
+ this._cy = ( Math.max( ...pts.map( p => p.y ) ) + Math.min( ...pts.map( p => p.y ) ) ) / 2;
727
+ this._render( );
728
+ }
729
+
730
+ setMarkers( markers: MapMarker[] ) {
731
+ this._markers = markers ?? [];
732
+ this._buildMarkers( );
733
+ this._render( );
734
+ }
735
+
736
+ addMarker( marker: MapMarker ) {
737
+ this.setMarkers( [ ...this._markers, marker ] );
738
+ }
739
+
740
+ removeMarker( marker: MapMarker ) {
741
+ this.setMarkers( this._markers.filter( m => m!==marker ) );
742
+ }
743
+
744
+ getMarkers( ) {
745
+ return this._markers;
746
+ }
747
+
748
+ setShapes( shapes: MapShape[] ) {
749
+ this._shapes = shapes ?? [];
750
+ this._render( );
751
+ }
752
+
753
+ addShape( shape: MapShape ) {
754
+ this.setShapes( [ ...this._shapes, shape ] );
755
+ }
756
+
757
+ removeShape( shape: MapShape ) {
758
+ this.setShapes( this._shapes.filter( s => s!==shape ) );
759
+ }
760
+
761
+ getShapes( ) {
762
+ return this._shapes;
763
+ }
764
+ }