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.
- package/aicontext.md +380 -19
- package/package.json +7 -3
- package/src/components/bargraph/bargraph.module.scss +44 -0
- package/src/components/bargraph/bargraph.ts +362 -0
- package/src/components/base.scss +71 -1
- package/src/components/calendar/calendar.module.scss +6 -5
- package/src/components/calendar/calendar.ts +14 -8
- package/src/components/canvas/canvas.ts +5 -4
- package/src/components/carousel/carousel.module.scss +165 -0
- package/src/components/carousel/carousel.ts +423 -0
- package/src/components/chart/chart.module.scss +8 -0
- package/src/components/chart/chart.ts +170 -12
- package/src/components/combobox/combobox.module.scss +2 -1
- package/src/components/components.ts +3 -0
- package/src/components/gantt/gantt.module.scss +462 -0
- package/src/components/gantt/gantt.ts +1254 -0
- package/src/components/knob/knob.module.scss +113 -0
- package/src/components/knob/knob.ts +355 -0
- package/src/components/mapview/mapview.module.scss +177 -0
- package/src/components/mapview/mapview.ts +764 -0
- package/src/components/planner/planner.module.scss +688 -0
- package/src/components/planner/planner.ts +1792 -0
- package/src/components/radio/radio.module.scss +4 -1
- package/src/components/slider/slider.module.scss +22 -20
- package/src/components/switch/switch.module.scss +68 -44
- package/src/components/switch/switch.ts +6 -3
- package/src/components/tag/tag.module.scss +4 -4
- package/src/components/textedit/textedit.module.scss +5 -2
- package/src/components/themes.scss +19 -6
- package/src/core/core_i18n.ts +65 -0
- package/src/core/core_tools.ts +44 -0
- 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
|
+
}
|