google-maps-area-editor 0.1.0

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/AreaEditor.js ADDED
@@ -0,0 +1,709 @@
1
+ import { toRadians, toDegrees, Vector2 } from './math.js';
2
+
3
+ export const STATE = Object.freeze({
4
+ INITIAL: "initial",
5
+ DRAWABLE: "drawable",
6
+ DRAWING: "drawing",
7
+ EDITING: "editing",
8
+ });
9
+
10
+ export class AreaType {
11
+ #name;
12
+ #color;
13
+
14
+ /**
15
+ * @param {string} name - Type identifier (used for serialization/deserialization)
16
+ * @param {string} color - Display color (CSS color string)
17
+ */
18
+ constructor(name, color) {
19
+ this.#name = name;
20
+ this.#color = color;
21
+ }
22
+
23
+ /** @type {string} */
24
+ get name() { return this.#name; }
25
+ /** @type {string} */
26
+ get color() { return this.#color; }
27
+ }
28
+
29
+ const SVG_PATH = {
30
+ CIRCLE: "M0,-5 a5,5 0 1,0 0,10 a5,5 0 1,0 0,-10",
31
+ DOUBLE_CIRCLE: "M0,-5 a5,5 0 1,0 0,10 a5,5 0 1,0 0,-10 M0,-2.5 a2.5,2.5 0 1,0 0,5 a2.5,2.5 0 1,0 0,-5",
32
+ };
33
+
34
+ function createMarkerSvg(path, scale) {
35
+ // Match original google.maps.Marker Symbol rendering:
36
+ // path coordinates scaled by `scale`, stroke always 1px
37
+ const size = Math.ceil((5 * scale + 1) * 2);
38
+ const half = size / 2;
39
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
40
+ svg.setAttribute("width", size);
41
+ svg.setAttribute("height", size);
42
+ svg.setAttribute("viewBox", `${-half} ${-half} ${size} ${size}`);
43
+ svg.style.display = "block";
44
+ svg.style.transform = "translateY(50%)";
45
+ const pathEl = document.createElementNS("http://www.w3.org/2000/svg", "path");
46
+ pathEl.setAttribute("d", path);
47
+ pathEl.setAttribute("fill", "#ffffff");
48
+ pathEl.setAttribute("stroke", "#000000");
49
+ pathEl.setAttribute("stroke-width", "1");
50
+ pathEl.setAttribute("vector-effect", "non-scaling-stroke");
51
+ if (scale !== 1) pathEl.setAttribute("transform", `scale(${scale})`);
52
+ svg.appendChild(pathEl);
53
+ return svg;
54
+ }
55
+
56
+ function createDraggableContent(size) {
57
+ const div = document.createElement("div");
58
+ div.style.width = `${size}px`;
59
+ div.style.height = `${size}px`;
60
+ div.style.transform = "translateY(50%)";
61
+ return div;
62
+ }
63
+
64
+ export class AreaEditor {
65
+ #map;
66
+ #drawing;
67
+ /** @type {Area[]} */
68
+ #areas = [];
69
+ /** @type {Area|null} */
70
+ #editingArea = null;
71
+ #currentState = STATE.INITIAL;
72
+ #areaType;
73
+ #eventLayer;
74
+ /** @type {Map<string, AreaType>} */
75
+ #typesMap;
76
+ #mapClickListener;
77
+
78
+ onStateChange = (state) => {};
79
+
80
+ /**
81
+ * Creates an AreaEditor. Automatically loads required Google Maps libraries (geometry, marker).
82
+ * @param {google.maps.Map} map
83
+ * @param {Object} [options]
84
+ * @param {AreaType[]} options.types - Available area types
85
+ * @returns {Promise<AreaEditor>}
86
+ */
87
+ static async create(map, options = {}) {
88
+ await google.maps.importLibrary("geometry");
89
+ await google.maps.importLibrary("marker");
90
+ return new AreaEditor(map, options);
91
+ }
92
+
93
+ /**
94
+ * @param {google.maps.Map} map
95
+ * @param {Object} [options]
96
+ * @param {AreaType[]} options.types - Available area types
97
+ */
98
+ constructor(map, options = {}) {
99
+ this.#map = map;
100
+ this.#typesMap = new Map((options.types || []).map(t => [t.name, t]));
101
+ this.#drawing = new Drawing(this.#map);
102
+
103
+ this.#eventLayer = document.createElement("div");
104
+ this.#eventLayer.style.position = "absolute";
105
+ this.#eventLayer.style.top = "0";
106
+ this.#eventLayer.style.left = "0";
107
+ this.#eventLayer.style.width = "100%";
108
+ this.#eventLayer.style.height = "100%";
109
+ this.#eventLayer.style.zIndex = "10";
110
+ this.#eventLayer.style.cursor = "crosshair";
111
+ this.#eventLayer.style.display = "none";
112
+
113
+ const mapDiv = this.#map.getDiv();
114
+ mapDiv.appendChild(this.#eventLayer);
115
+
116
+ this.#mapClickListener = this.#map.addListener("click", () => {
117
+ this.endEditArea();
118
+ });
119
+
120
+ this.#initEvents();
121
+ }
122
+
123
+ #setState(state) {
124
+ this.#currentState = state;
125
+
126
+ if (state === STATE.DRAWABLE || state === STATE.DRAWING) {
127
+ this.#eventLayer.style.display = "block";
128
+ } else {
129
+ this.#eventLayer.style.display = "none";
130
+ }
131
+
132
+ if (typeof this.onStateChange === "function") {
133
+ this.onStateChange(state);
134
+ }
135
+ }
136
+
137
+ #isState(state) {
138
+ return this.#currentState === state;
139
+ }
140
+
141
+ #initEvents() {
142
+ this.#eventLayer.addEventListener("mousedown", event => {
143
+ if (event.button === 0) {
144
+ event.preventDefault();
145
+ if (this.#isState(STATE.DRAWABLE)) {
146
+ this.#drawing.begin(event.offsetX, event.offsetY);
147
+ this.#setState(STATE.DRAWING);
148
+ }
149
+ }
150
+ });
151
+
152
+ this.#eventLayer.addEventListener("mousemove", event => {
153
+ event.preventDefault();
154
+ if (this.#isState(STATE.DRAWING)) {
155
+ this.#drawing.move(event.offsetX, event.offsetY);
156
+ }
157
+ });
158
+
159
+ this.#eventLayer.addEventListener("mouseup", event => {
160
+ if (event.button === 0) {
161
+ event.preventDefault();
162
+ if (this.#isState(STATE.DRAWING)) {
163
+ this.#drawing.end(event.offsetX, event.offsetY);
164
+ this.#setState(STATE.DRAWABLE);
165
+ const area = this.#drawing.toArea(this.#areaType);
166
+ if (area) {
167
+ area.onclick = () => {
168
+ this.beginEditArea(area, false);
169
+ };
170
+ this.#areas.push(area);
171
+ this.beginEditArea(area, false);
172
+ }
173
+ }
174
+ }
175
+ });
176
+
177
+ this.#eventLayer.addEventListener("mouseout", event => {
178
+ event.preventDefault();
179
+ if (this.#isState(STATE.DRAWING)) {
180
+ this.#drawing.end(event.offsetX, event.offsetY);
181
+ this.#setState(STATE.DRAWABLE);
182
+ }
183
+ });
184
+ }
185
+
186
+ // --- Public API ---
187
+
188
+ setAreaType(type) {
189
+ this.endEditArea();
190
+ this.#areaType = type;
191
+ this.#setState(STATE.DRAWABLE);
192
+ }
193
+
194
+ cancelAdd() {
195
+ if (this.#isState(STATE.DRAWING)) {
196
+ this.#drawing.end();
197
+ }
198
+ this.#setState(STATE.INITIAL);
199
+ }
200
+
201
+ loadAreas(jsonString) {
202
+ this.#clearAreas();
203
+ if (!jsonString) return;
204
+ const parsed = JSON.parse(jsonString);
205
+ this.#areas = parsed.map(a => {
206
+ const type = this.#typesMap.get(a.type);
207
+ if (!type) {
208
+ throw new Error(`Unknown area type: "${a.type}"`);
209
+ }
210
+ const area = Area.fromUIArea(this.#map, type, a);
211
+ area.onclick = () => {
212
+ this.beginEditArea(area, false);
213
+ };
214
+ return area;
215
+ });
216
+ }
217
+
218
+ exportAreasJSON() {
219
+ return JSON.stringify(this.#areas.map(a => a.toUIArea()));
220
+ }
221
+
222
+ beginEditArea(area, mapCenter) {
223
+ if (this.#editingArea) {
224
+ this.#editingArea.editable = false;
225
+ }
226
+ area.editable = true;
227
+ this.#editingArea = area;
228
+ this.#setState(STATE.EDITING);
229
+ if (mapCenter) {
230
+ this.#map.setCenter(this.#editingArea.center);
231
+ }
232
+ }
233
+
234
+ endEditArea() {
235
+ if (this.#editingArea) {
236
+ this.#editingArea.editable = false;
237
+ this.#editingArea = null;
238
+ this.#setState(STATE.INITIAL);
239
+ }
240
+ }
241
+
242
+ editNext() {
243
+ if (this.#areas.length) {
244
+ const i = this.#areas.indexOf(this.#editingArea);
245
+ this.beginEditArea(this.#areas[(i + 1 + this.#areas.length) % this.#areas.length], true);
246
+ }
247
+ }
248
+
249
+ editPrev() {
250
+ if (this.#areas.length) {
251
+ const i = this.#areas.indexOf(this.#editingArea);
252
+ this.beginEditArea(this.#areas[(Math.max(i, 0) - 1 + this.#areas.length) % this.#areas.length], true);
253
+ }
254
+ }
255
+
256
+ removeEditingArea() {
257
+ if (this.#editingArea) {
258
+ const i = this.#areas.indexOf(this.#editingArea);
259
+ if (i >= 0) {
260
+ this.#editingArea.destroy();
261
+ this.#editingArea = null;
262
+ this.#areas.splice(i, 1);
263
+ this.#setState(STATE.INITIAL);
264
+ }
265
+ }
266
+ }
267
+
268
+ destroy() {
269
+ this.cancelAdd();
270
+ this.endEditArea();
271
+ this.#clearAreas();
272
+ this.#drawing.destroy();
273
+ google.maps.event.removeListener(this.#mapClickListener);
274
+ this.#eventLayer.remove();
275
+ }
276
+
277
+ // --- Private helpers ---
278
+
279
+ #clearAreas() {
280
+ this.#areas.forEach(a => a.destroy());
281
+ this.#areas = [];
282
+ }
283
+ }
284
+
285
+ // Internal helper classes and functions
286
+
287
+ function toLatLng(map, x, y) {
288
+ const projection = map.getProjection();
289
+ const bounds = map.getBounds();
290
+ const ne = projection.fromLatLngToPoint(bounds.getNorthEast());
291
+ const sw = projection.fromLatLngToPoint(bounds.getSouthWest());
292
+ const scale = 1 << map.getZoom();
293
+ return projection.fromPointToLatLng(new google.maps.Point(x / scale + sw.x, y / scale + ne.y));
294
+ }
295
+
296
+ function toVector(latLngFrom, latLngTo) {
297
+ return Vector2.create(
298
+ toRadians(google.maps.geometry.spherical.computeHeading(latLngFrom, latLngTo)),
299
+ google.maps.geometry.spherical.computeDistanceBetween(latLngFrom, latLngTo)
300
+ );
301
+ }
302
+
303
+ function computeOffset(from, distance, angle) {
304
+ return google.maps.geometry.spherical.computeOffset(from, Math.abs(distance), toDegrees(angle + (distance < 0 ? Math.PI : 0)));
305
+ }
306
+
307
+ function latLngArrayToBounds(points) {
308
+ const bounds = new google.maps.LatLngBounds();
309
+ points.forEach(p => bounds.extend(p));
310
+ return bounds;
311
+ }
312
+
313
+ class Drawing {
314
+ #map;
315
+ #rectangle;
316
+ #drawing = false;
317
+ #origin;
318
+ #diagonal;
319
+
320
+ constructor(map) {
321
+ this.#map = map;
322
+ this.#rectangle = new google.maps.Rectangle({
323
+ strokeColor: "#000000",
324
+ strokeOpacity: 1.0,
325
+ strokeWeight: 1,
326
+ fillColor: "#000000",
327
+ fillOpacity: 0.1,
328
+ clickable: false,
329
+ map: this.#map,
330
+ zIndex: 1000,
331
+ });
332
+ }
333
+
334
+ begin(x, y) {
335
+ if (!this.#drawing) {
336
+ this.#origin = toLatLng(this.#map, x, y);
337
+ this.#diagonal = this.#origin;
338
+ this.#showBounds();
339
+ this.#drawing = true;
340
+ }
341
+ }
342
+
343
+ move(x, y) {
344
+ if (this.#drawing) {
345
+ this.#diagonal = toLatLng(this.#map, x, y);
346
+ this.#showBounds();
347
+ }
348
+ }
349
+
350
+ end(x, y) {
351
+ if (this.#drawing) {
352
+ if (Number.isFinite(x) && Number.isFinite(y)) {
353
+ this.#diagonal = toLatLng(this.#map, x, y);
354
+ }
355
+ this.#hideBounds();
356
+ this.#drawing = false;
357
+ }
358
+ }
359
+
360
+ toArea(type) {
361
+ return this.#origin
362
+ && this.#diagonal
363
+ && !this.#origin.equals(this.#diagonal)
364
+ && Area.create(
365
+ this.#map,
366
+ type,
367
+ google.maps.geometry.spherical.interpolate(this.#origin, this.#diagonal, 0.5),
368
+ toVector(this.#origin, this.#diagonal)
369
+ );
370
+ }
371
+
372
+ #showBounds() {
373
+ const bounds = latLngArrayToBounds([this.#origin, this.#diagonal]);
374
+ this.#rectangle.setBounds(bounds);
375
+ }
376
+
377
+ #hideBounds() {
378
+ this.#rectangle.setBounds(null);
379
+ }
380
+
381
+ destroy() {
382
+ this.#rectangle.setMap(null);
383
+ }
384
+ }
385
+
386
+ class Area {
387
+ static create(map, type, center, vDiagonal) {
388
+ return new Area(map, type, center, Math.abs(vDiagonal.y), Math.abs(vDiagonal.x), 0);
389
+ }
390
+
391
+ static fromUIArea(map, type, area) {
392
+ return new Area(
393
+ map,
394
+ type,
395
+ new google.maps.LatLng(area.latitude, area.longitude),
396
+ area.width,
397
+ area.height,
398
+ area.angle
399
+ );
400
+ }
401
+
402
+ #map;
403
+ #type;
404
+ #center;
405
+ #width;
406
+ #height;
407
+ #angle;
408
+ #distN;
409
+ #distE;
410
+ #polygon;
411
+ #corners = [[1, 1], [-1, 1], [-1, -1], [1, -1]];
412
+ #anchors;
413
+ #rotator;
414
+ #editable = false;
415
+ #listeners = [];
416
+ onclick = () => { };
417
+
418
+ constructor(map, type, center, width, height, angle) {
419
+ this.#map = map;
420
+ this.#type = type;
421
+ this.#center = center;
422
+ this.#width = width;
423
+ this.#height = height;
424
+ this.#angle = angle;
425
+ this.#distN = height / 2;
426
+ this.#distE = width / 2;
427
+
428
+ this.#polygon = new google.maps.Polygon({
429
+ strokeColor: type.color,
430
+ strokeOpacity: 1.0,
431
+ strokeWeight: 1,
432
+ fillColor: type.color,
433
+ fillOpacity: 0.1,
434
+ clickable: true,
435
+ draggable: false,
436
+ map: this.#map,
437
+ });
438
+
439
+ this.#listeners.push(this.#polygon.addListener("click", () => this.onclick()));
440
+
441
+ this.#listeners.push(this.#polygon.addListener("drag", () => {
442
+ const path = this.#polygon.getPath();
443
+ this.#center = google.maps.geometry.spherical.interpolate(path.getAt(0), path.getAt(2), 0.5);
444
+ this.#showAnchors();
445
+ this.#showRotator();
446
+ }));
447
+
448
+ this.#anchors = [
449
+ new AreaAnchor(this.#map, 1, 1), new AreaAnchor(this.#map, 0, 1), new AreaAnchor(this.#map, -1, 1),
450
+ new AreaAnchor(this.#map, -1, 0), new AreaAnchor(this.#map, -1, -1), new AreaAnchor(this.#map, 0, -1),
451
+ new AreaAnchor(this.#map, 1, -1), new AreaAnchor(this.#map, 1, 0)
452
+ ];
453
+
454
+ this.#rotator = new AreaRotator(this.#map);
455
+
456
+ this.#showBounds();
457
+ this.#showAnchors();
458
+
459
+ this.#anchors.forEach(a => {
460
+ a.onresize = (center, height, width) => {
461
+ this.#center = center;
462
+ if (Number.isFinite(height)) {
463
+ this.#height = height;
464
+ this.#distN = height / 2;
465
+ }
466
+ if (Number.isFinite(width)) {
467
+ this.#width = width;
468
+ this.#distE = width / 2;
469
+ }
470
+ this.#showBounds();
471
+ this.#showAnchors();
472
+ this.#showRotator();
473
+ };
474
+ });
475
+
476
+ this.#showRotator();
477
+ this.#rotator.onrotate = angle => {
478
+ this.#angle = angle;
479
+ this.#showBounds();
480
+ this.#showAnchors();
481
+ this.#showRotator();
482
+ };
483
+
484
+ this.#listeners.push(this.#map.addListener("zoom_changed", () => {
485
+ this.#showRotator();
486
+ }));
487
+ }
488
+
489
+ get center() { return this.#center; }
490
+ get bounds() { return this.#polygon.getPath().getArray(); }
491
+ get editable() { return this.#editable; }
492
+ set editable(editable) {
493
+ this.#editable = editable;
494
+ this.#showBounds();
495
+ this.#showAnchors();
496
+ this.#showRotator();
497
+ }
498
+
499
+ destroy() {
500
+ this.#listeners.forEach(l => google.maps.event.removeListener(l));
501
+ this.#rotator.destroy();
502
+ this.#anchors.forEach(a => a.destroy());
503
+ this.#polygon.setMap(null);
504
+ }
505
+
506
+ toUIArea() {
507
+ return {
508
+ type: this.#type.name,
509
+ latitude: this.#center.lat(),
510
+ longitude: this.#center.lng(),
511
+ width: this.#width,
512
+ height: this.#height,
513
+ angle: this.#angle,
514
+ };
515
+ }
516
+
517
+ #showBounds() {
518
+ this.#polygon.setPath(this.#corners.map(c => {
519
+ const v = new Vector2(this.#distN * c[0], this.#distE * c[1]);
520
+ return computeOffset(this.#center, v.magnitude, v.angle + this.#angle);
521
+ }));
522
+ this.#polygon.setOptions({
523
+ draggable: this.#editable,
524
+ zIndex: this.#editable ? 101 : 100,
525
+ });
526
+ }
527
+
528
+ #showAnchors() {
529
+ this.#anchors.forEach(a => a.show(this.#center, this.#distN, this.#distE, this.#angle, this.#editable));
530
+ }
531
+
532
+ #showRotator() {
533
+ this.#rotator.show(this.#center, this.#distN, this.#angle, this.#editable);
534
+ }
535
+ }
536
+
537
+ class AreaAnchor {
538
+ #map;
539
+ #timesN;
540
+ #timesE;
541
+ #marker;
542
+ #draggable;
543
+ #origin;
544
+ #vectorN;
545
+ #vectorE;
546
+ #dragging;
547
+ onresize = (center, height, width) => { };
548
+
549
+ constructor(map, timesN, timesE) {
550
+ this.#map = map;
551
+ this.#timesN = timesN;
552
+ this.#timesE = timesE;
553
+
554
+ this.#marker = new google.maps.marker.AdvancedMarkerElement({
555
+ map: this.#map,
556
+ content: createMarkerSvg(SVG_PATH.CIRCLE, 1.0),
557
+ gmpClickable: false,
558
+ zIndex: 200,
559
+ });
560
+
561
+ const createDraggable = () => {
562
+ const marker = new google.maps.marker.AdvancedMarkerElement({
563
+ map: this.#map,
564
+ content: createDraggableContent(12),
565
+ gmpDraggable: true,
566
+ zIndex: 201,
567
+ });
568
+ marker.style.opacity = "0";
569
+ const listeners = [];
570
+ listeners.push(marker.addListener("dragstart", () => {
571
+ this.#dragging = this.#draggable;
572
+ this.#draggable = createDraggable();
573
+ }));
574
+ listeners.push(marker.addListener("drag", event => doResize(event)));
575
+ listeners.push(marker.addListener("dragend", event => {
576
+ listeners.forEach(l => google.maps.event.removeListener(l));
577
+ this.#dragging.map = null;
578
+ this.#dragging = null;
579
+ doResize(event);
580
+ }));
581
+ return marker;
582
+ };
583
+
584
+ this.#draggable = createDraggable();
585
+
586
+ const doResize = event => {
587
+ if (this.#dragging) {
588
+ const v = toVector(this.#origin, event.latLng);
589
+ const height = this.#timesN ? this.#vectorN.dot(v) : null;
590
+ const width = this.#timesE ? this.#vectorE.dot(v) : null;
591
+ const cv = this.#timesN && this.#timesE ? v :
592
+ this.#timesN ? this.#vectorN.times(height) :
593
+ this.#vectorE.times(width);
594
+ this.onresize(computeOffset(this.#origin, cv.magnitude / 2, cv.angle), height, width);
595
+ }
596
+ };
597
+ }
598
+
599
+ show(center, distN, distE, angle, editable) {
600
+ const vn = new Vector2(distN * this.#timesN, 0).rot(angle);
601
+ const ve = new Vector2(0, distE * this.#timesE).rot(angle);
602
+ const v = vn.add(ve);
603
+ const position = computeOffset(center, v.magnitude, v.angle);
604
+ this.#marker.position = position;
605
+ this.#marker.map = editable ? this.#map : null;
606
+ this.#draggable.position = position;
607
+ this.#draggable.map = editable ? this.#map : null;
608
+ if (!this.#dragging) {
609
+ this.#origin = computeOffset(center, v.magnitude, v.angle + Math.PI);
610
+ this.#vectorN = vn.times(Math.sign(distN)).normalized;
611
+ this.#vectorE = ve.times(Math.sign(distE)).normalized;
612
+ }
613
+ }
614
+
615
+ destroy() {
616
+ // TODO: Ideally should also remove event listeners from draggable markers
617
+ if (this.#marker) this.#marker.map = null;
618
+ if (this.#draggable) this.#draggable.map = null;
619
+ if (this.#dragging) this.#dragging.map = null;
620
+ }
621
+ }
622
+
623
+ class AreaRotator {
624
+ #map;
625
+ #line;
626
+ #marker;
627
+ #draggable;
628
+ #dragging;
629
+ #center;
630
+ #inverse;
631
+ onrotate = angle => { };
632
+
633
+ constructor(map) {
634
+ this.#map = map;
635
+ this.#line = new google.maps.Polyline({
636
+ strokeColor: "#000000",
637
+ strokeOpacity: 1.0,
638
+ strokeWeight: 1.0,
639
+ map: this.#map,
640
+ clickable: false,
641
+ draggable: false,
642
+ zIndex: 190,
643
+ });
644
+
645
+ this.#marker = new google.maps.marker.AdvancedMarkerElement({
646
+ map: this.#map,
647
+ content: createMarkerSvg(SVG_PATH.DOUBLE_CIRCLE, 2.0),
648
+ gmpClickable: false,
649
+ zIndex: 200,
650
+ });
651
+
652
+ const createDraggable = () => {
653
+ const marker = new google.maps.marker.AdvancedMarkerElement({
654
+ map: this.#map,
655
+ content: createDraggableContent(22),
656
+ gmpDraggable: true,
657
+ zIndex: 201,
658
+ });
659
+ marker.style.opacity = "0";
660
+ const listeners = [];
661
+ listeners.push(marker.addListener("dragstart", () => {
662
+ this.#dragging = this.#draggable;
663
+ this.#draggable = createDraggable();
664
+ }));
665
+ listeners.push(marker.addListener("drag", event => doRotate(event)));
666
+ listeners.push(marker.addListener("dragend", event => {
667
+ listeners.forEach(l => google.maps.event.removeListener(l));
668
+ this.#dragging.map = null;
669
+ this.#dragging = null;
670
+ doRotate(event);
671
+ }));
672
+ return marker;
673
+ };
674
+
675
+ this.#draggable = createDraggable();
676
+
677
+ const doRotate = event => {
678
+ if (this.#dragging) {
679
+ let v = toVector(this.#center, event.latLng);
680
+ if (this.#inverse) {
681
+ v = v.inverse;
682
+ }
683
+ this.onrotate(v.angle);
684
+ }
685
+ };
686
+ }
687
+
688
+ show(center, distN, angle, editable) {
689
+ const scale = 1 << this.#map.getZoom();
690
+ const positionN = computeOffset(center, distN, angle);
691
+ const position = computeOffset(positionN, 5000000 * Math.sign(distN) / scale, angle);
692
+ this.#line.setPath([positionN, position]);
693
+ this.#line.setVisible(editable);
694
+ this.#marker.position = position;
695
+ this.#marker.map = editable ? this.#map : null;
696
+ this.#draggable.position = position;
697
+ this.#draggable.map = editable ? this.#map : null;
698
+ this.#center = center;
699
+ this.#inverse = distN < 0;
700
+ }
701
+
702
+ destroy() {
703
+ // TODO: Ideally should also remove event listeners from draggable markers
704
+ this.#line?.setMap(null);
705
+ if (this.#marker) this.#marker.map = null;
706
+ if (this.#draggable) this.#draggable.map = null;
707
+ if (this.#dragging) this.#dragging.map = null;
708
+ }
709
+ }
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 hiroyukashi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,159 @@
1
+ # google-maps-area-editor
2
+
3
+ A rectangular area editor component for Google Maps JavaScript API.
4
+ Allows users to draw, resize, rotate, and manage rectangular areas on a Google Map.
5
+
6
+ ## Features
7
+
8
+ - Draw rectangular areas by mouse drag
9
+ - Resize areas via corner and edge anchors
10
+ - Rotate areas via a rotation handle
11
+ - Multiple area type support with custom colors
12
+ - Serialize / deserialize areas as JSON
13
+
14
+ ## Requirements
15
+
16
+ - Google Maps JavaScript API (v3.56+) with an API key
17
+ - The library uses `AdvancedMarkerElement` internally (loaded automatically by `AreaEditor.create()`). The map **must** be initialized with a `mapId`. For testing, you can use `mapId: 'DEMO_MAP_ID'`.
18
+ - The map **must** have `isFractionalZoomEnabled: false` set. The internal pixel-to-coordinate conversion uses `1 << map.getZoom()`, which requires integer zoom levels. Fractional zoom also causes excessive event firing that leads to rendering issues.
19
+
20
+ ## Usage
21
+
22
+ ```html
23
+ <script type="module">
24
+ import { AreaEditor, AreaType, STATE } from './AreaEditor.js';
25
+
26
+ // Define your area types
27
+ const AREA_TYPES = {
28
+ usual: new AreaType("usual", "#00bbdd"),
29
+ dangerous: new AreaType("dangerous", "#ff0000"),
30
+ custom: new AreaType("custom", "#9900ff"),
31
+ };
32
+
33
+ const { Map } = await google.maps.importLibrary("maps");
34
+
35
+ const map = new Map(document.getElementById("map"), {
36
+ center: { lat: 35.681236, lng: 139.767125 },
37
+ zoom: 18,
38
+ mapId: 'DEMO_MAP_ID', // Required for AdvancedMarkerElement
39
+ isFractionalZoomEnabled: false,
40
+ });
41
+
42
+ // Create editor (automatically loads geometry & marker libraries)
43
+ const editor = await AreaEditor.create(map, {
44
+ types: Object.values(AREA_TYPES),
45
+ });
46
+
47
+ // Listen for state changes
48
+ editor.onStateChange = state => {
49
+ console.log("State:", state); // STATE.INITIAL | STATE.DRAWABLE | STATE.DRAWING | STATE.EDITING
50
+ };
51
+
52
+ // Start drawing
53
+ editor.setAreaType(AREA_TYPES.usual);
54
+
55
+ // Cancel drawing mode
56
+ editor.cancelAdd();
57
+
58
+ // Navigate / remove editing area
59
+ editor.editNext();
60
+ editor.editPrev();
61
+ editor.removeEditingArea();
62
+
63
+ // Load / export
64
+ editor.loadAreas(jsonString);
65
+ const json = editor.exportAreasJSON();
66
+
67
+ // Clean up
68
+ editor.destroy();
69
+ </script>
70
+ ```
71
+
72
+ ## API
73
+
74
+ ### `AreaType`
75
+
76
+ ```js
77
+ new AreaType(name, color)
78
+ ```
79
+
80
+ - `name` (string) — Identifier used for serialization / deserialization.
81
+ - `color` (string) — CSS color string for the area's stroke and fill.
82
+
83
+ ### `AreaEditor`
84
+
85
+ #### Static
86
+
87
+ | Method | Description |
88
+ |---|---|
89
+ | `AreaEditor.create(map, options?)` | Async factory. Loads required Google Maps libraries and returns an `AreaEditor` instance. |
90
+
91
+ #### Constructor Options
92
+
93
+ | Option | Type | Description |
94
+ |---|---|---|
95
+ | `types` | `AreaType[]` | Array of area types available for use. Required for `loadAreas()` to resolve type names. |
96
+
97
+ #### Instance Methods
98
+
99
+ | Method | Description |
100
+ |---|---|
101
+ | `setAreaType(type)` | Enter drawing mode for the given `AreaType`. |
102
+ | `cancelAdd()` | Cancel drawing mode. |
103
+ | `loadAreas(jsonString)` | Load areas from JSON. Destroys any existing areas first. |
104
+ | `exportAreasJSON()` | Returns areas as a JSON string. |
105
+ | `editNext()` | Edit the next area. |
106
+ | `editPrev()` | Edit the previous area. |
107
+ | `removeEditingArea()` | Remove the currently editing area. |
108
+ | `destroy()` | Remove all areas, event listeners, and the overlay from the map. |
109
+
110
+ #### Callback
111
+
112
+ | Property | Description |
113
+ |---|---|
114
+ | `onStateChange` | `(state: string) => void` — Called when editor state changes. |
115
+
116
+ ### `STATE`
117
+
118
+ Exported constants for state comparison:
119
+
120
+ ```js
121
+ STATE.INITIAL // "initial"
122
+ STATE.DRAWABLE // "drawable"
123
+ STATE.DRAWING // "drawing"
124
+ STATE.EDITING // "editing"
125
+ ```
126
+
127
+ ## JSON Format
128
+
129
+ Each area is serialized as:
130
+
131
+ ```json
132
+ {
133
+ "type": "usual",
134
+ "latitude": 35.681236,
135
+ "longitude": 139.767125,
136
+ "width": 100,
137
+ "height": 50,
138
+ "angle": 0.5
139
+ }
140
+ ```
141
+
142
+ - `type` — Matches the `name` of an `AreaType`.
143
+ - `latitude` / `longitude` — Center of the area.
144
+ - `width` / `height` — Size in meters.
145
+ - `angle` — Rotation angle in radians.
146
+
147
+ ## Demo
148
+
149
+ > **Note:** Because this project uses ES modules (`<script type="module">`), you cannot run the demo by directly opening `index.html` in your browser (via the `file://` protocol) due to CORS restrictions. Please use a local web server (e.g., VS Code Live Server, `npx serve`, or `python -m http.server`) to view it.
150
+
151
+ Open `index.html` in a browser to try the editor. To use your own API key:
152
+
153
+ 1. Copy `config.example.js` to `config.local.js`
154
+ 2. Replace `YOUR_API_KEY` with your Google Maps API key
155
+ 3. `config.local.js` is gitignored and will not be committed
156
+
157
+ ## License
158
+
159
+ MIT
package/math.js ADDED
@@ -0,0 +1,65 @@
1
+ export function toRadians(degrees) {
2
+ return degrees * Math.PI / 180;
3
+ }
4
+
5
+ export function toDegrees(radians) {
6
+ return radians * 180 / Math.PI;
7
+ }
8
+
9
+ export class Vector2 {
10
+ /** @type {number} */
11
+ #x;
12
+ /** @type {number} */
13
+ #y;
14
+ /** @type {number} */
15
+ #magnitude;
16
+ /** @type {number} */
17
+ #angle;
18
+
19
+ /**
20
+ * @param {number} x
21
+ * @param {number} y
22
+ */
23
+ constructor(x, y) {
24
+ this.#x = x;
25
+ this.#y = y;
26
+ this.#magnitude = Math.sqrt(Math.pow(x, 2.0) + Math.pow(y, 2.0));
27
+ this.#angle = Math.atan2(y, x);
28
+ }
29
+
30
+ get x() { return this.#x; }
31
+ get y() { return this.#y; }
32
+ get magnitude() { return this.#magnitude; }
33
+ get angle() { return this.#angle; }
34
+
35
+ get normalized() {
36
+ return new Vector2(this.#x / this.#magnitude, this.#y / this.#magnitude);
37
+ }
38
+
39
+ get inverse() {
40
+ return this.times(-1.0);
41
+ }
42
+
43
+ times(n) {
44
+ return new Vector2(this.#x * n, this.#y * n);
45
+ }
46
+
47
+ add(v) {
48
+ return new Vector2(this.#x + v.#x, this.#y + v.#y);
49
+ }
50
+
51
+ rot(angle) {
52
+ return new Vector2(
53
+ this.#x * Math.cos(angle) - this.#y * Math.sin(angle),
54
+ this.#x * Math.sin(angle) + this.#y * Math.cos(angle)
55
+ );
56
+ }
57
+
58
+ dot(v) {
59
+ return this.#x * v.#x + this.#y * v.#y;
60
+ }
61
+
62
+ static create(angle, magnitude) {
63
+ return new Vector2(magnitude * Math.cos(angle), magnitude * Math.sin(angle));
64
+ }
65
+ }
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "google-maps-area-editor",
3
+ "version": "0.1.0",
4
+ "description": "An intuitive, draggable, and rotatable area editor component for the Google Maps JavaScript API.",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": "./AreaEditor.js"
8
+ },
9
+ "files": [
10
+ "AreaEditor.js",
11
+ "math.js"
12
+ ],
13
+ "keywords": [
14
+ "google-maps",
15
+ "area",
16
+ "editor",
17
+ "rectangle",
18
+ "polygon"
19
+ ],
20
+ "author": "hiroyukashi",
21
+ "license": "MIT",
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "https://github.com/hiroyukashi/google-maps-area-editor.git"
25
+ }
26
+ }