leaflet-rotate 0.5.0 → 0.5.2

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/test/index.html CHANGED
@@ -1,205 +1,579 @@
1
1
  <!DOCTYPE html>
2
2
  <html>
3
3
 
4
- <head>
5
- <title>leaflet-rotate.js</title>
6
- <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
7
- <meta name="viewport" content="initial-scale=1.0, user-scalable=no" />
4
+ <head>
5
+ <title>leaflet-rotate.js</title>
6
+ <meta charset="UTF-8">
7
+ <meta name="viewport" content="initial-scale=1.0, user-scalable=no" />
8
+ <link id="leaflet-css" rel="stylesheet">
9
+ <script src="../misc/route.js"></script>
10
+ <script src="../misc/places.js"></script>
11
+ <script src="../misc/loremIpsum.js"></script>
12
+ <style>
13
+ body { font: 13px sans-serif; margin: 0; padding: 8px; color: #222; }
14
+ header { display: flex; flex-wrap: wrap; align-items: stretch; gap: 6px 8px; }
15
+ header h1 { font-size: 16px; margin: 0 8px 0 0; align-self: center; }
16
+ header h1 a { color: inherit; text-decoration: none; }
17
+ fieldset { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 10px; margin: 0; padding: 2px 8px 4px; border: 1px solid #ccc; border-radius: 4px; }
18
+ legend { color: #777; font-size: 11px; padding: 0 4px; }
19
+ #scenario { max-width: 22em; }
20
+ .hint { color: #777; margin: 6px 0; }
21
+ kbd { font: 11px monospace; border: 1px solid #ccc; border-radius: 3px; padding: 0 3px; background: #f6f6f6; }
22
+ #maps { display: flex; gap: 8px; }
23
+ #maps > div { flex: 1; min-width: 0; }
24
+ #maps h2 { font-size: 13px; margin: 0 0 2px; }
25
+ .map { height: 60vh; border: 1px solid #ccc; }
26
+ .info { font-family: monospace; padding: 2px 0; color: #555; }
27
+ #panels { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(480px, 100%), 1fr)); gap: 8px; margin-top: 8px; align-items: start; }
28
+ details { border: 1px solid #ccc; border-radius: 4px; padding: 4px 8px; min-width: 0; overflow-x: auto; }
29
+ summary { cursor: pointer; font-weight: bold; }
30
+ summary button { font-weight: normal; margin-left: 8px; }
31
+ #results { border-collapse: collapse; margin-top: 6px; width: 100%; }
32
+ #results td, #results th { border: 1px solid #ddd; padding: 2px 6px; text-align: left; }
33
+ #results td.bad { background: #fdd; }
34
+ #results .running { background: #ffd; }
35
+ .hidden { display: none !important; }
36
+ </style>
37
+ </head>
8
38
 
9
- <link id="leaflet-css" rel="stylesheet">
39
+ <body>
40
+ <header>
41
+ <h1><a href="https://github.com/Raruto/leaflet-rotate" title="View on GitHub">leaflet-rotate</a></h1>
42
+ <fieldset>
43
+ <legend>Setup</legend>
44
+ <label>Leaflet <select id="leaflet-version"><option value="1">1.9.4</option><option value="2">2.0.0-alpha.1</option></select></label>
45
+ <label title="Compare with MapLibre GL side by side"><input type="checkbox" id="maplibre"> MapLibre</label>
46
+ <label class="ml-only" title="Keep both views in sync (the hovered map drives the other one)"><input type="checkbox" id="sync" checked> sync</label>
47
+ <label title="Show the leaflet-rotate debug overlays and info panel"><input type="checkbox" id="debug"> debug</label>
48
+ <label title="MapLibre-like continuous zoom (needed for an exact view sync)"><input type="checkbox" id="snap"> zoomSnap: 0</label>
49
+ </fieldset>
50
+ <fieldset>
51
+ <legend>Scenarios</legend>
52
+ <select id="scenario"></select>
53
+ <button id="run">Run</button>
54
+ <button id="run-all">Run all</button>
55
+ </fieldset>
56
+ </header>
57
+ <p class="hint"><kbd>Ctrl</kbd>+drag / right-drag: pitch + bearing &middot; <kbd>Shift</kbd>+wheel: rotate &middot; <kbd>Ctrl</kbd>+wheel: trackpad pinch &middot; click the compass to reset bearing + pitch</p>
58
+ <main id="maps">
59
+ <div><h2 class="ml-only">leaflet-rotate</h2><div id="lf" class="map"></div><div id="lf-info" class="info"></div></div>
60
+ <div class="ml-only"><h2>MapLibre GL</h2><div id="ml" class="map"></div><div id="ml-info" class="info"></div></div>
61
+ </main>
62
+ <section id="panels">
63
+ <details id="results-panel" class="hidden" open>
64
+ <summary>Scenario results <button id="clear">Clear</button></summary>
65
+ <table id="results">
66
+ <thead><tr><th>Scenario</th><th>leaflet-rotate z / bearing / pitch</th><th>drift</th><th class="ml-only">MapLibre z / bearing / pitch</th><th class="ml-only">drift</th><th class="ml-only">view Δ</th><th class="ml-only" title="Largest change in the relative positions of six geographic probes, compared with the other renderer.">max spatial Δ</th></tr></thead>
67
+ <tbody></tbody>
68
+ </table>
69
+ </details>
70
+ </section>
10
71
 
11
- <!-- include some miscellaneous -->
12
- <script src="../misc/route.js"></script>
13
- <script src="../misc/places.js"></script>
14
- <script src="../misc/loremIpsum.js"></script>
72
+ <script type="module">
73
+ /**
74
+ * Interactive regression harness, optionally comparing Leaflet with MapLibre.
75
+ * Setup is persisted through query parameters: `leaflet=2`, `maplibre=0`,
76
+ * `debug=0`, and `snap=0` (continuous Leaflet zoom). Defaults enable comparison
77
+ * and debugging with Leaflet 1 and integer zoom snapping.
78
+ *
79
+ * Manual view sync is suspended during scenarios: each renderer receives the
80
+ * same starting view and input independently, then its results are measured.
81
+ */
82
+ const $ = id => document.getElementById(id);
83
+ const params = new URLSearchParams(location.search);
84
+ $('leaflet-version').value = params.get('leaflet') === '2' ? '2' : '1';
85
+ $('maplibre').checked = params.get('maplibre') !== '0';
86
+ $('debug').checked = params.get('debug') !== '0';
87
+ $('snap').checked = params.get('snap') === '0';
88
+ /** Reload the harness with the selected library/debug/zoom configuration. */
89
+ const reload = () => {
90
+ const query = new URLSearchParams({ leaflet: $('leaflet-version').value });
91
+ if (!$('maplibre').checked) query.set('maplibre', '0');
92
+ if (!$('debug').checked) query.set('debug', '0');
93
+ if ($('snap').checked) query.set('snap', '0');
94
+ location.search = query;
95
+ };
96
+ for (const id of ['leaflet-version', 'maplibre', 'debug', 'snap']) $(id).onchange = reload;
97
+ const compare = $('maplibre').checked;
98
+ for (const el of document.querySelectorAll('.ml-only')) el.classList.toggle('hidden', !compare);
15
99
 
16
- <style>
17
- #map {
18
- width: 100%;
19
- height: 600px;
20
- border: 1px solid #ccc;
21
- max-height: 50vh;
22
- }
23
- /* TODO: fix incorrect map corners position when map has some margin */
24
- html, body {
25
- /* margin: 0; */
26
- /* padding: 0; */
27
- }
28
- </style>
29
- </head>
100
+ /** Load scripts in dependency order; reject explicitly if a CDN or local file fails. */
101
+ const loadScript = src => new Promise((resolve, reject) => {
102
+ const script = Object.assign(document.createElement('script'), { src, onload: resolve, onerror: () => reject(new Error(`Could not load ${src}`)) });
103
+ document.head.appendChild(script);
104
+ });
105
+ const loadStyle = href => document.head.appendChild(Object.assign(document.createElement('link'), { rel: 'stylesheet', href }));
106
+ const v2 = $('leaflet-version').value === '2';
107
+ $('leaflet-css').href = `https://unpkg.com/leaflet@${v2 ? '2.0.0-alpha.1' : '1.9.4'}/dist/leaflet.css`;
108
+ await loadScript(`https://unpkg.com/leaflet@${v2 ? '2.0.0-alpha.1/dist/leaflet-global-src.js' : '1.9.4/dist/leaflet-src.js'}`);
109
+ if (v2) globalThis.L = globalThis.leaflet;
110
+ await loadScript('../dist/leaflet-rotate-src.js');
111
+ if ($('debug').checked) await loadScript('../lib/debug.js');
112
+ if (compare) {
113
+ loadStyle('https://unpkg.com/maplibre-gl@5/dist/maplibre-gl.css');
114
+ await loadScript('https://unpkg.com/maplibre-gl@5/dist/maplibre-gl.js');
115
+ }
116
+ const L = globalThis.L;
30
117
 
31
- <body>
118
+ const tiles = 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}';
119
+ const polygonLatLngs = [[48, -3], [50, -4], [52, 4]];
32
120
 
33
- <label for="leaflet-version">Leaflet version:</label>
34
- <select id="leaflet-version">
35
- <option value="1">1.9.4</option>
36
- <option value="2">2.0.0-alpha.1</option>
37
- </select>
121
+ // leaflet-rotate
122
+ const esri = window.esri = new L.TileLayer(tiles, {
123
+ maxZoom: 24,
124
+ maxNativeZoom: 18,
125
+ attribution: 'Tiles &copy; Esri &mdash; Source: Esri, i-cubed, USDA, USGS, AEX, GeoEye, Getmapping, Aerogrid, IGN, IGP, UPR-EGP, and the GIS User Community',
126
+ });
127
+ const osm = window.osm = new L.TileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
128
+ maxZoom: 24,
129
+ maxNativeZoom: 19,
130
+ attribution: '&copy; <a href="http://openstreetmap.org/copyright">OpenStreetMap</a> contributors',
131
+ });
132
+ const lf = window.lf = window.map = new L.Map('lf', {
133
+ center: [55, 10],
134
+ zoom: 2,
135
+ maxZoom: 20,
136
+ layers: [esri],
137
+ rotate: true,
138
+ bearing: 30,
139
+ rotateControl: { closeOnZeroBearing: false },
140
+ compassBearing: !compare,
141
+ touchRotate: true,
142
+ touchPitch: true,
143
+ zoomSnap: $('snap').checked ? 0 : 1,
144
+ });
145
+ window.layers = new L.Control.Layers({ 'Empty': new L.TileLayer(''), 'Streets': osm, 'Satellite': esri }, null, { collapsed: false }).addTo(lf);
146
+ window.markers = Object.keys(places).map((name, i) => new L.Marker(places[name], { draggable: true })
147
+ .bindPopup(`<b>${name}</b><br>${loremIpsum}`)
148
+ .bindTooltip(`<b>${name}</b>`, i ? {} : { direction: 'right', permanent: true })
149
+ .addTo(lf));
150
+ window.path = new L.Polyline(route, { color: 'yellow', renderer: new L.Canvas() }).addTo(lf);
151
+ window.circle = new L.Circle([53, 4], 111111).addTo(lf);
152
+ window.polygon = new L.Polygon(polygonLatLngs).addTo(lf);
153
+ window.rectangle = new L.Rectangle([[60.81123, 25.71632], [60.81028, 25.73166], [60.7997, 25.72892], [60.80061, 25.71362]]).addTo(lf);
154
+ if ($('debug').checked) {
155
+ L.Rotate.debug(lf, { container: $('panels') });
156
+ }
38
157
 
39
- <div id="map"></div>
40
-
41
- <hr>
158
+ // MapLibre (512px zoom levels => zoom - 1, bearing sign is inverted)
159
+ const ml = window.ml = compare ? new maplibregl.Map({
160
+ container: 'ml', center: [5, 50], zoom: 3, maxPitch: 85, maxZoom: 19,
161
+ style: {
162
+ version: 8,
163
+ sources: {
164
+ esri: { type: 'raster', tiles: [tiles], tileSize: 256, maxzoom: 18 },
165
+ polygon: { type: 'geojson', data: { type: 'Polygon', coordinates: [[...polygonLatLngs, polygonLatLngs[0]].map(([lat, lng]) => [lng, lat])] } },
166
+ route: { type: 'geojson', data: { type: 'LineString', coordinates: route.map(([lat, lng]) => [lng, lat]) } },
167
+ },
168
+ layers: [
169
+ { id: 'esri', type: 'raster', source: 'esri' },
170
+ { id: 'polygon', type: 'fill', source: 'polygon', paint: { 'fill-color': '#3388ff', 'fill-opacity': 0.2 } },
171
+ { id: 'polygon-line', type: 'line', source: 'polygon', paint: { 'line-color': '#3388ff', 'line-width': 3 } },
172
+ { id: 'route', type: 'line', source: 'route', paint: { 'line-color': 'yellow', 'line-width': 3 } },
173
+ ],
174
+ },
175
+ }) : null;
176
+ if (ml) {
177
+ ml.addControl(new maplibregl.NavigationControl({ visualizePitch: true }));
178
+ for (const name in places) new maplibregl.Marker().setLngLat([places[name][1], places[name][0]]).setPopup(new maplibregl.Popup().setText(name)).addTo(ml);
179
+ }
42
180
 
43
- <div>
44
- <p>
45
- <strong>Fly to bounds: </strong>
46
- <button onclick="map.flyToBounds(circle.getBounds());">CIRCLE</button>
47
- <button onclick="map.flyToBounds(polygon.getBounds());"> POLYGON</button>
48
- <button onclick="map.flyToBounds(path.getBounds());">PATH</button>
49
- </p>
50
- <p>
51
- <strong>Fly to: </strong>
52
- <button onclick="map.flyTo(places['Kyiv'], 10);">Kyiv</button>
53
- <button onclick="map.flyTo(places['London'], 10);">London</button>
54
- <button onclick="map.flyTo(places['San Francisco'], 10);">San Francisco</button>
55
- <button onclick="map.flyTo(places['Washington'], 10);">Washington</button>
56
- <button onclick="map.flyTo(places['Trondheim'], 10);">Trondheim</button>
57
- </p>
58
- <p>
59
- <strong>Fit bounds (z = 0): </strong>
60
- <button onclick="map.setZoom(0, {animate: false}) && setTimeout(() => map.fitBounds(rectangle.getBounds(), { animate: false }), 1000);" title="DEBUG: map.setZoom(0) && map.getBoundsZoom(rectangle.getBounds())">RECTANGLE</button>
61
- </p>
62
- </div>
181
+ /**
182
+ * @typedef {Object} View
183
+ * @property {number} lat Center latitude in degrees.
184
+ * @property {number} lng Center longitude in degrees.
185
+ * @property {number} zoom Leaflet zoom scale (256px world at zoom 0).
186
+ * @property {number} bearing Leaflet bearing sign, in degrees.
187
+ * @property {number} pitch Pitch in degrees.
188
+ */
189
+ /**
190
+ * Renderer adapters use View values and [lat, lng] geographic arrays.
191
+ * `get`/`set` read/apply a view without animation; `toLatLng`/`toPoint` convert
192
+ * container pixels to/from geography. MapLibre uses 512px zoom levels,
193
+ * the opposite bearing sign, and [lng, lat] arrays internally.
194
+ */
195
+ const maps = {
196
+ lf: {
197
+ el: lf.getContainer(),
198
+ get: () => { const c = lf.getCenter(); return { lat: c.lat, lng: c.lng, zoom: lf.getZoom(), bearing: lf.getBearing(), pitch: lf.getPitch() }; },
199
+ set: v => { lf.setView([v.lat, v.lng], v.zoom, { animate: false }); lf.setPitch(v.pitch); lf.setBearing(v.bearing); },
200
+ toLatLng: p => { const ll = lf.containerPointToLatLng(p); return [ll.lat, ll.lng]; },
201
+ toPoint: ([lat, lng]) => { const p = lf.latLngToContainerPoint([lat, lng]); return [p.x, p.y]; },
202
+ },
203
+ };
204
+ if (ml) maps.ml = {
205
+ el: ml.getContainer(),
206
+ get: () => { const c = ml.getCenter(); return { lat: c.lat, lng: c.lng, zoom: ml.getZoom() + 1, bearing: (360 - ml.getBearing()) % 360, pitch: ml.getPitch() }; },
207
+ set: v => ml.jumpTo({ center: [v.lng, v.lat], zoom: v.zoom - 1, bearing: -v.bearing, pitch: v.pitch }),
208
+ toLatLng: p => { const ll = ml.unproject(p); return [ll.lat, ll.lng]; },
209
+ toPoint: ([lat, lng]) => { const p = ml.project([lng, lat]); return [p.x, p.y]; },
210
+ };
63
211
 
64
- <script type="module">
65
- const versionSelect = document.getElementById('leaflet-version');
66
- const params = new URLSearchParams(location.search);
67
- versionSelect.value = params.get('leaflet') === '2' ? '2' : '1';
68
- versionSelect.addEventListener('change', () => {
69
- location.search = `?leaflet=${versionSelect.value}`;
70
- });
212
+ // view sync: the last hovered map drives the other one
213
+ let source = 'lf', syncing = false, running = false;
214
+ const fmt = v => `z ${v.zoom.toFixed(2)} bearing ${v.bearing.toFixed(1)} pitch ${v.pitch.toFixed(1)} ${v.lat.toFixed(4)}, ${v.lng.toFixed(4)}`;
215
+ /** Create a view listener; the hovered renderer drives sync, without recursive updates. */
216
+ const onView = key => () => {
217
+ $(`${key}-info`).textContent = fmt(maps[key].get());
218
+ if (!ml || key !== source || syncing || running || !$('sync').checked) return;
219
+ syncing = true;
220
+ const other = key === 'lf' ? 'ml' : 'lf';
221
+ maps[other].set(maps[key].get());
222
+ $(`${other}-info`).textContent = fmt(maps[other].get());
223
+ syncing = false;
224
+ };
225
+ lf.on('move zoom rotate pitch', onView('lf'));
226
+ onView('lf')();
227
+ if (ml) ml.on('move', onView('ml'));
228
+ for (const key in maps) maps[key].el.addEventListener('pointerenter', () => source = key);
71
229
 
72
- const loadScript = src => new Promise((resolve, reject) => {
73
- const script = document.createElement('script');
74
- script.src = src;
75
- script.onload = resolve;
76
- script.onerror = () => reject(new Error(`Could not load ${src}`));
77
- document.head.appendChild(script);
78
- });
230
+ const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
231
+ const frame = () => new Promise(resolve => requestAnimationFrame(resolve));
232
+ /**
233
+ * Create synthetic mouse/pointer/wheel input for one renderer.
234
+ * Positions are container fractions (0..1 inside, outside values allowed).
235
+ * Emit both pointer and mouse events for the supported library versions.
236
+ * These untrusted events exercise handlers, not native browser input behavior;
237
+ * animation-frame pacing also depends on tab visibility.
238
+ *
239
+ * @param {'lf'|'ml'} key Renderer receiving the input.
240
+ * @returns {Object} Async down/move/up/wheel methods and a millisecond wait helper.
241
+ */
242
+ const driver = key => {
243
+ const el = maps[key].el;
244
+ let pressed = 0, button = 0;
245
+ /** Convert fractional container coordinates to viewport client pixels. */
246
+ const at = (fx, fy) => {
247
+ const r = el.getBoundingClientRect();
248
+ return { clientX: r.left + r.width * fx, clientY: r.top + r.height * fy };
249
+ };
250
+ /** Hit-test layers/markers; use body outside this map so document listeners still run. */
251
+ const target = ({ clientX, clientY }) => {
252
+ const hit = document.elementFromPoint(clientX, clientY);
253
+ return hit && el.contains(hit) ? hit : document.body;
254
+ };
255
+ const fire = (type, pos, mods = {}) => {
256
+ const init = { bubbles: true, cancelable: true, composed: true, view: window, ...pos, button, buttons: pressed, ...mods };
257
+ const t = target(pos);
258
+ t.dispatchEvent(new PointerEvent(`pointer${type}`, { ...init, pointerId: 1, pointerType: 'mouse', isPrimary: true }));
259
+ t.dispatchEvent(new MouseEvent(`mouse${type}`, init));
260
+ };
261
+ let last = at(0.5, 0.5);
262
+ return {
263
+ /** Press the selected mouse button at a fractional container position. */
264
+ async down(fx, fy, mods = {}) {
265
+ button = mods.button || 0;
266
+ pressed = button === 2 ? 2 : 1;
267
+ fire('down', last = at(fx, fy), mods);
268
+ await frame();
269
+ },
270
+ /** Interpolate from the last position, yielding one animation frame per step. */
271
+ async move(fx, fy, mods = {}, steps = 10) {
272
+ const from = last, to = at(fx, fy);
273
+ for (let i = 1; i <= steps; i++) {
274
+ last = { clientX: from.clientX + (to.clientX - from.clientX) * i / steps, clientY: from.clientY + (to.clientY - from.clientY) * i / steps };
275
+ fire('move', last, mods);
276
+ await frame();
277
+ }
278
+ },
279
+ /** Release at the last position, including contextmenu for right-button gestures. */
280
+ async up(mods = {}) {
281
+ pressed = 0;
282
+ fire('up', last, mods);
283
+ if (button === 2) target(last).dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true, ...last, button: 2, ...mods }));
284
+ await frame();
285
+ },
286
+ /** Send a wheel burst with pixel deltas and a millisecond interval between events. */
287
+ async wheel(fx, fy, deltaY, mods = {}, count = 1, interval = 16) {
288
+ last = at(fx, fy);
289
+ for (let i = 0; i < count; i++) {
290
+ target(last).dispatchEvent(new WheelEvent('wheel', { bubbles: true, cancelable: true, ...last, deltaY, deltaMode: 0, ...mods }));
291
+ await sleep(interval);
292
+ }
293
+ },
294
+ wait: sleep,
295
+ };
296
+ };
79
297
 
80
- const version = versionSelect.value;
81
- document.getElementById('leaflet-css').href = version === '2'
82
- ? 'https://unpkg.com/leaflet@2.0.0-alpha.1/dist/leaflet.css'
83
- : 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css';
298
+ /**
299
+ * @typedef {Object} Scenario
300
+ * @property {string} name Display label.
301
+ * @property {Partial<View>} [view] Overrides of the shared `flat` starting view.
302
+ * @property {number[][]} [anchor] [from, to] container fractions expected to hold
303
+ * the same geographic point; absent when cursor anchoring is not measured.
304
+ * @property {boolean} [noInertia] Disable/stop inertia for endpoint measurements.
305
+ * @property {function(Object): Promise<void>} [run] Synthetic gesture using a driver.
306
+ * @property {string} [goTo] Navigation destination, used instead of `run`.
307
+ * @property {number} [settle=900] Milliseconds to wait after input/navigation completes.
308
+ */
309
+ const ctrl = { ctrlKey: true }, right = { button: 2 };
310
+ /** Build a modified drag, using Ctrl by default or an explicit right-button modifier. */
311
+ const ctrlDrag = (from, to, steps, mods = ctrl) => async d => { await d.down(...from, mods); await d.move(...to, mods, steps); await d.up(mods); };
312
+ /** Build one uninterrupted plain drag through fractional-container waypoints. */
313
+ const panPath = (...points) => async d => { await d.down(...points[0]); for (const point of points.slice(1)) await d.move(...point, {}, 12); await d.up(); };
314
+ const flat = { lat: 50, lng: 5, zoom: 5, bearing: 0, pitch: 0 };
315
+ const scenarios = [
316
+ { name: 'Ctrl+drag up: pitch from center', run: ctrlDrag([0.5, 0.5], [0.5, 0.3]) },
317
+ { name: 'Ctrl+drag down: pitch clamps at 0', view: { pitch: 20 }, run: ctrlDrag([0.5, 0.4], [0.5, 0.9]) },
318
+ { name: 'Ctrl+drag far up: pitch clamps at max', run: ctrlDrag([0.5, 0.95], [0.5, 0.0], 30) },
319
+ { name: 'Ctrl+drag right, just above center', run: ctrlDrag([0.3, 0.45], [0.7, 0.45]) },
320
+ { name: 'Ctrl+drag right, just below center', run: ctrlDrag([0.3, 0.55], [0.7, 0.55]) },
321
+ { name: 'Ctrl+drag right, far above center (angular)', run: ctrlDrag([0.2, 0.05], [0.8, 0.05]) },
322
+ { name: 'Ctrl+drag right, far below center (angular)', run: ctrlDrag([0.2, 0.95], [0.8, 0.95]) },
323
+ { name: 'Ctrl+drag diagonal, pitched + rotated', view: { bearing: 40, pitch: 30 }, run: ctrlDrag([0.3, 0.7], [0.7, 0.3]) },
324
+ { name: 'Ctrl+drag fast (2 big steps)', run: ctrlDrag([0.2, 0.8], [0.8, 0.2], 2) },
325
+ {
326
+ name: 'Ctrl+drag full circle around center', run: async d => {
327
+ await d.down(0.5, 0.1, ctrl);
328
+ for (let a = 0; a <= 360; a += 30) await d.move(0.5 + 0.4 * Math.sin(a * Math.PI / 180), 0.5 - 0.4 * Math.cos(a * Math.PI / 180), ctrl, 3);
329
+ await d.up(ctrl);
330
+ },
331
+ },
332
+ { name: 'Ctrl+drag leaving the map', run: ctrlDrag([0.5, 0.5], [1.3, 0.2], 20) },
333
+ { name: 'Ctrl+drag starting on a marker', view: { lat: places.London[0], lng: places.London[1], zoom: 6 }, run: ctrlDrag([0.5, 0.49], [0.7, 0.3]) },
334
+ { name: 'Ctrl released mid-drag', run: async d => { await d.down(0.5, 0.6, ctrl); await d.move(0.6, 0.5, ctrl, 5); await d.move(0.7, 0.4, {}, 5); await d.up(); } },
335
+ { name: 'Ctrl pressed mid-pan (should keep panning)', anchor: [[0.5, 0.5], [0.7, 0.4]], run: async d => { await d.down(0.5, 0.5); await d.move(0.6, 0.45, {}, 5); await d.move(0.7, 0.4, ctrl, 5); await d.up(ctrl); } },
336
+ { name: 'Right-drag up + right', run: ctrlDrag([0.4, 0.6], [0.6, 0.4], 10, right) },
337
+ { name: 'Ctrl+drag then plain pan', anchor: [[0.4, 0.5], [0.6, 0.6]], run: async d => { await ctrlDrag([0.5, 0.5], [0.6, 0.4])(d); await d.down(0.4, 0.5); await d.move(0.6, 0.6); await d.up(); } },
338
+ { name: 'Wheel in at top-right, flat (control case)', anchor: [[0.85, 0.15], [0.85, 0.15]], run: async d => { await d.wheel(0.85, 0.15, -100, {}, 3, 30); } },
339
+ { name: 'Wheel in at top-right, rotated', view: { bearing: 30 }, anchor: [[0.85, 0.15], [0.85, 0.15]], run: async d => { await d.wheel(0.85, 0.15, -100, {}, 3, 30); } },
340
+ { name: 'Wheel in at top-right, pitch 60', view: { bearing: 30, pitch: 60 }, anchor: [[0.85, 0.15], [0.85, 0.15]], run: async d => { await d.wheel(0.85, 0.15, -100, {}, 3, 30); } },
341
+ { name: 'Wheel out at bottom-left, pitch 45', view: { bearing: 200, pitch: 45, zoom: 7 }, anchor: [[0.1, 0.9], [0.1, 0.9]], run: async d => { await d.wheel(0.1, 0.9, 100, {}, 3, 30); } },
342
+ { name: 'Wheel near horizon, pitch 75', view: { pitch: 75 }, anchor: [[0.5, 0.25], [0.5, 0.25]], run: async d => { await d.wheel(0.5, 0.25, -100, {}, 2, 30); } },
343
+ { name: 'Ctrl+wheel pinch-in burst (trackpad), pitch 50', view: { bearing: 20, pitch: 50 }, anchor: [[0.8, 0.7], [0.8, 0.7]], run: async d => { await d.wheel(0.8, 0.7, -4, ctrl, 30); } },
344
+ { name: 'Ctrl+wheel pinch-out burst (trackpad), rotated', view: { bearing: 60, zoom: 7 }, anchor: [[0.2, 0.3], [0.2, 0.3]], run: async d => { await d.wheel(0.2, 0.3, 4, ctrl, 30); } },
345
+ { name: 'Ctrl+wheel mouse notches at center', view: { pitch: 40 }, anchor: [[0.5, 0.5], [0.5, 0.5]], run: async d => { await d.wheel(0.5, 0.5, -100, ctrl, 2, 200); } },
346
+ { name: 'Shift+wheel rotate', run: async d => { await d.wheel(0.5, 0.5, 100, { shiftKey: true }, 6, 30); } },
347
+ { name: 'Wheel then Ctrl+drag while zoom pending', run: async d => { await d.wheel(0.9, 0.1, -100); await ctrlDrag([0.5, 0.5], [0.55, 0.4], 4)(d); } },
348
+ { name: 'Wheel during Ctrl+drag', run: async d => { await d.down(0.5, 0.6, ctrl); await d.move(0.55, 0.5, ctrl, 4); await d.wheel(0.55, 0.5, -100, ctrl); await d.move(0.6, 0.4, ctrl, 4); await d.up(ctrl); } },
349
+ { name: 'Low zoom rotated, wheel in (layer jump check)', view: { zoom: 1, bearing: 30 }, anchor: [[0.6, 0.4], [0.6, 0.4]], run: async d => { await d.wheel(0.6, 0.4, -100, {}, 1); await d.wait(400); await d.wheel(0.6, 0.4, -100, {}, 1); } },
350
+ { name: 'Pan pitched 70', view: { bearing: 45, pitch: 70 }, anchor: [[0.5, 0.7], [0.3, 0.5]], noInertia: true, run: async d => { await d.down(0.5, 0.7); await d.move(0.3, 0.5); await d.up(); } },
351
+ { name: 'Pan pitched 70: vertical up', view: { bearing: 45, pitch: 70 }, anchor: [[0.5, 0.7], [0.5, 0.5]], noInertia: true, run: panPath([0.5, 0.7], [0.5, 0.5]) },
352
+ { name: 'Pan pitched 70: vertical down', view: { bearing: 45, pitch: 70 }, anchor: [[0.5, 0.3], [0.5, 0.5]], noInertia: true, run: panPath([0.5, 0.3], [0.5, 0.5]) },
353
+ { name: 'Pan pitched 70: horizontal left', view: { bearing: 45, pitch: 70 }, anchor: [[0.7, 0.5], [0.5, 0.5]], noInertia: true, run: panPath([0.7, 0.5], [0.5, 0.5]) },
354
+ { name: 'Pan pitched 70: horizontal right', view: { bearing: 45, pitch: 70 }, anchor: [[0.3, 0.5], [0.5, 0.5]], noInertia: true, run: panPath([0.3, 0.5], [0.5, 0.5]) },
355
+ { name: 'Pan pitched 70: diagonal up-right', view: { bearing: 45, pitch: 70 }, anchor: [[0.3, 0.7], [0.5, 0.5]], noInertia: true, run: panPath([0.3, 0.7], [0.5, 0.5]) },
356
+ { name: 'Pan pitched 70: diagonal down-left', view: { bearing: 45, pitch: 70 }, anchor: [[0.7, 0.3], [0.5, 0.5]], noInertia: true, run: panPath([0.7, 0.3], [0.5, 0.5]) },
357
+ { name: 'Pan pitched 70: reverse vertical drag', view: { bearing: 45, pitch: 70 }, anchor: [[0.5, 0.7], [0.5, 0.4]], noInertia: true, run: panPath([0.5, 0.7], [0.5, 0.5], [0.5, 0.7], [0.5, 0.4]) },
358
+ { name: 'Pan pitched 70: multi-direction loop', view: { bearing: 45, pitch: 70 }, anchor: [[0.5, 0.7], [0.5, 0.7]], noInertia: true, run: panPath([0.5, 0.7], [0.7, 0.5], [0.3, 0.5], [0.5, 0.7]) },
359
+ { name: 'Pan pitched 70, bearing 225: diagonal', view: { bearing: 225, pitch: 70 }, anchor: [[0.3, 0.7], [0.7, 0.3]], noInertia: true, run: panPath([0.3, 0.7], [0.7, 0.3]) },
360
+ ];
361
+ scenarios.push(...['Circle', 'Polygon', 'Path', ...Object.keys(places), 'Rectangle (fit from z 0)'].map(name => ({
362
+ name: `Go to: ${name}`,
363
+ goTo: name,
364
+ view: { bearing: 45, pitch: 70 },
365
+ settle: 300,
366
+ })));
367
+ for (const [i, s] of scenarios.entries()) $('scenario').add(new Option(s.name, i));
84
368
 
85
- if (version === '2') {
86
- await loadScript('https://unpkg.com/leaflet@2.0.0-alpha.1/dist/leaflet-global-src.js');
87
- globalThis.L = globalThis.leaflet;
369
+ /**
370
+ * Run equivalent fly/fit navigation, preserving each renderer's bearing and pitch.
371
+ * Wait for moveend (8s timeout), not a fixed flight duration. Fit algorithms and
372
+ * zoom snapping differ between renderers, so final views need not match exactly.
373
+ *
374
+ * @param {'lf'|'ml'} key Renderer to navigate.
375
+ * @param {string} name Layer bounds, a `places` key, or the rectangle reset case.
376
+ */
377
+ const runGoTo = async (key, name) => {
378
+ const map = key === 'lf' ? lf : ml,
379
+ // Subscribe before invoking the action: nonanimated fits can end synchronously.
380
+ finishMove = action => {
381
+ const finished = new Promise((resolve, reject) => {
382
+ const onMoveEnd = () => {
383
+ clearTimeout(timer);
384
+ resolve();
385
+ };
386
+ const timer = setTimeout(() => {
387
+ map.off('moveend', onMoveEnd);
388
+ reject(new Error(`Go to "${name}" did not finish moving`));
389
+ }, 8000);
390
+ map.once('moveend', onMoveEnd);
391
+ });
392
+ action();
393
+ return finished;
394
+ },
395
+ view = maps[key].get(),
396
+ mapLibreCamera = { bearing: -view.bearing, pitch: view.pitch };
397
+ if (name === 'Circle' || name === 'Polygon' || name === 'Path') {
398
+ const bounds = name === 'Circle' ? circle.getBounds() : name === 'Polygon' ? polygon.getBounds() : path.getBounds();
399
+ if (key === 'lf') {
400
+ await finishMove(() => lf.flyToBounds(bounds));
401
+ } else {
402
+ await finishMove(() => ml.fitBounds([[bounds.getWest(), bounds.getSouth()], [bounds.getEast(), bounds.getNorth()]], { ...mapLibreCamera, duration: 1000 }));
403
+ }
404
+ } else if (name === 'Rectangle (fit from z 0)') {
405
+ if (key === 'lf') {
406
+ lf.setZoom(0, { animate: false });
407
+ await sleep(1000);
408
+ await finishMove(() => lf.fitBounds(rectangle.getBounds(), { animate: false }));
409
+ } else {
410
+ ml.setZoom(-1);
411
+ await sleep(1000);
412
+ const bounds = rectangle.getBounds();
413
+ await finishMove(() => ml.fitBounds([[bounds.getWest(), bounds.getSouth()], [bounds.getEast(), bounds.getNorth()]], { ...mapLibreCamera, duration: 0 }));
414
+ }
88
415
  } else {
89
- await loadScript('https://unpkg.com/leaflet@1.9.4/dist/leaflet-src.js');
416
+ const latlng = places[name];
417
+ if (key === 'lf') {
418
+ await finishMove(() => lf.flyTo(latlng, 10));
419
+ } else {
420
+ await finishMove(() => ml.flyTo({ center: [latlng[1], latlng[0]], zoom: 9, ...mapLibreCamera, duration: 1000 }));
421
+ }
90
422
  }
91
- await loadScript('../dist/leaflet-rotate-src.js');
92
- await loadScript('../lib/debug.js');
93
- const L = globalThis.L;
94
-
95
- const esri = new L.TileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', {
96
- id: 'mapbox.streets',
97
- maxZoom: 24,
98
- maxNativeZoom: 18,
99
- attribution: 'Tiles &copy; Esri &mdash; Source: Esri, i-cubed, USDA, USGS, AEX, GeoEye, Getmapping, Aerogrid, IGN, IGP, UPR-EGP, and the GIS User Community',
100
- // noWrap: true
101
- });
102
-
103
- const osm = new L.TileLayer('http://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
104
- maxZoom: 24,
105
- maxNativeZoom: 19,
106
- attribution: '&copy; <a href="http://openstreetmap.org/copyright">OpenStreetMap</a> contributors',
107
- // noWrap: true
108
- });
109
-
110
- const map = new L.Map('map', {
111
- center: [55, 10],
112
- zoom: 2,
113
- layers: [esri],
114
- // worldCopyJump: true,
115
- // preferCanvas: false,
116
- zoomAnimation: false, /* DEBUG: L.Renderer._updateTransform() */
117
- rotate: true,
118
- rotateControl: {
119
- closeOnZeroBearing: false,
120
- // position: 'bottomleft',
121
- },
122
- bearing: 30,
123
- // attributionControl: false,
124
- // zoomControl: false,
125
- compassBearing: true,
126
- // trackContainerMutation: false,
127
- // shiftKeyRotate: false,
128
- // touchGestures: true,
129
- touchRotate: true,
130
- // touchZoom: true
131
- });
132
- window.map = map;
133
- window.esri = esri;
134
- window.osm = osm;
423
+ };
135
424
 
136
- // map.setBearing(90);
137
- // map.touchRotate.enable();
138
-
139
- // map.touchZoom.disable()
140
- // map.compassBearing.disable()
141
- // map.touchGestures.enable()
142
-
143
- // map.zoomControl.setPosition('bottomleft');
144
- // map.rotateControl.setPosition('bottomleft');
145
- // map.setMaxBounds([[-90,-180], [90,180]]);
146
-
147
- const layers = new L.Control.Layers({
148
- 'Empty': new L.TileLayer(''),
149
- 'Streets': osm,
150
- 'Satellite': esri,
151
- }, null, {
152
- collapsed: false
153
- }).addTo(map);
154
- window.layers = layers;
155
-
156
- const markers = [];
157
- for (const i in places) {
158
- markers.push(new L.Marker(places[i], {
159
- draggable: true
160
- })
161
- .bindPopup('<b>' + i + '</b><br>' + loremIpsum)
162
- .bindTooltip('<b>' + i + '</b>',
163
- markers.length ? {} : {
164
- direction: 'right',
165
- permanent: true
166
- }
167
- )
168
- .addTo(map));
425
+ /**
426
+ * Reset both views, run one renderer, and measure its independent final state.
427
+ *
428
+ * Drift is the screen distance between the original geographic anchor and its
429
+ * expected endpoint. For spatial delta, first sync the other renderer to the
430
+ * final view, then compare six fixed geographic probes relative to viewport
431
+ * centers, subtracting each probe's initial cross-renderer difference.
432
+ * Initially non-finite probes are excluded; a valid probe becoming non-finite
433
+ * produces Infinity. After large flights, probes can be offscreen/near the
434
+ * horizon: spatial delta is a diagnostic, not a viewport-wide accuracy bound.
435
+ *
436
+ * @param {'lf'|'ml'} key Renderer whose input is being tested.
437
+ * @param {Scenario} scenario Gesture or navigation definition.
438
+ * @returns {Promise<{end: View, drift: ?number, spatialDelta: ?number}>}
439
+ * Final view and pixel errors; null denotes an unavailable measurement.
440
+ */
441
+ const runOn = async (key, scenario) => {
442
+ const m = maps[key];
443
+ const initialView = { ...flat, ...scenario.view },
444
+ other = maps[key === 'lf' ? 'ml' : 'lf'];
445
+ for (const map of Object.values(maps)) map.set(initialView);
446
+ await sleep(300);
447
+ let relativePoints, initialDifferences;
448
+ if (other) {
449
+ const probes = [[0.2, 0.2], [0.8, 0.2], [0.2, 0.8], [0.8, 0.8], [0.5, 0.25], [0.5, 0.75]]
450
+ .map(([x, y]) => {
451
+ const r = m.el.getBoundingClientRect();
452
+ return m.toLatLng([r.width * x, r.height * y]);
453
+ });
454
+ // Center-relative coordinates remove the offset from unequal container sizes.
455
+ relativePoints = map => {
456
+ const r = map.el.getBoundingClientRect();
457
+ return probes.map(ll => {
458
+ const p = map.toPoint(ll);
459
+ return p.every(Number.isFinite) ? [p[0] - r.width / 2, p[1] - r.height / 2] : null;
460
+ });
461
+ };
462
+ const initialLf = relativePoints(maps.lf), initialMl = relativePoints(maps.ml);
463
+ initialDifferences = initialLf.map((p, i) => p && initialMl[i]
464
+ ? [p[0] - initialMl[i][0], p[1] - initialMl[i][1]]
465
+ : null);
466
+ if (!initialDifferences.some(Boolean)) throw new Error('No common finite geographic probes for this view');
169
467
  }
170
- window.markers = markers;
171
-
172
- const path = new L.Polyline(route, {
173
- renderer: new L.Canvas()
174
- }).addTo(map);
175
- window.path = path;
176
-
177
- const circle = new L.Circle([53, 4], 111111)
178
- .addTo(map);
179
- window.circle = circle;
180
-
181
- const polygon = new L.Polygon([
182
- [48, -3],
183
- [50, -4],
184
- [52, 4]
185
- ]).addTo(map);
186
- window.polygon = polygon;
187
-
188
- const rectangle = new L.Rectangle([
189
- [60.81123, 25.71632],
190
- [60.81028, 25.73166],
191
- [60.7997, 25.72892],
192
- [60.80061, 25.71362]
193
- ]).addTo(map);
194
- window.rectangle = rectangle;
195
-
196
- // Display some debug info
197
- L.Rotate.debug(map);
198
- </script>
199
-
200
- <a href="https://github.com/Raruto/leaflet-rotate" class="view-on-github" style="position: fixed;bottom: 10px;left: calc(50% - 60px);z-index: 9999;"> <img alt="View on Github" src="https://raruto.github.io/img/view-on-github.png" title="View on Github"
201
- width="163"> </a>
468
+ const anchor = scenario.anchor && (() => {
469
+ const r = m.el.getBoundingClientRect();
470
+ return scenario.anchor.map(([fx, fy]) => [r.width * fx, r.height * fy]);
471
+ })();
472
+ const latlng = anchor && m.toLatLng(anchor[0]);
473
+ const inertia = key === 'lf' && scenario.noInertia ? lf.options.inertia : undefined;
474
+ if (inertia !== undefined) lf.options.inertia = false;
475
+ try {
476
+ if (scenario.goTo) await runGoTo(key, scenario.goTo);
477
+ else await scenario.run(driver(key));
478
+ if (key === 'ml' && scenario.noInertia) ml.stop();
479
+ await sleep(scenario.settle || 900);
480
+ } finally {
481
+ if (inertia !== undefined) lf.options.inertia = inertia;
482
+ }
483
+ const end = m.get(), p = anchor && m.toPoint(latlng);
484
+ let spatialDelta = null;
485
+ if (other) {
486
+ other.set(end);
487
+ await sleep(100);
488
+ const endLf = relativePoints(maps.lf), endMl = relativePoints(maps.ml);
489
+ spatialDelta = Math.max(...initialDifferences.map((baseline, i) => {
490
+ if (!baseline) return 0;
491
+ if (!endLf[i] || !endMl[i]) return Infinity;
492
+ const point = endLf[i];
493
+ const dx = point[0] - endMl[i][0] - initialDifferences[i][0],
494
+ dy = point[1] - endMl[i][1] - initialDifferences[i][1];
495
+ return Math.hypot(dx, dy);
496
+ }));
497
+ }
498
+ return {
499
+ end,
500
+ drift: anchor ? Math.hypot(p[0] - anchor[1][0], p[1] - anchor[1][1]) : null,
501
+ spatialDelta,
502
+ };
503
+ };
504
+ /**
505
+ * Run a scenario sequentially on each enabled renderer and append a result row.
506
+ * Compare independent final views before spatial synchronization affects them:
507
+ * center distance uses wrapped Web Mercator pixels at the mean Leaflet zoom.
508
+ * Red cells flag drift/spatial/center errors >3px, zoom differences >0.05,
509
+ * or bearing/pitch differences >0.1 degrees; these are diagnostic thresholds.
510
+ *
511
+ * @param {Scenario} scenario Selected test case.
512
+ * @returns {Promise<Object[]>} Per-renderer measurements in adapter iteration order.
513
+ */
514
+ const run = async scenario => {
515
+ running = true;
516
+ $('run').disabled = $('run-all').disabled = true;
517
+ $('results-panel').classList.remove('hidden');
518
+ const row = $('results').tBodies[0].insertRow();
519
+ row.className = 'running';
520
+ row.insertCell().textContent = scenario.name;
521
+ // sequential: both libraries listen on document/window while dragging
522
+ const results = [];
523
+ for (const key in maps) results.push(await runOn(key, scenario));
524
+ for (const r of results) {
525
+ row.insertCell().textContent = `${r.end.zoom.toFixed(2)} / ${r.end.bearing.toFixed(1)} / ${r.end.pitch.toFixed(1)}`;
526
+ const cell = row.insertCell();
527
+ cell.textContent = r.drift == null ? '' : `${r.drift.toFixed(1)}px`;
528
+ cell.classList.toggle('bad', r.drift > 3);
529
+ }
530
+ let viewDelta = null, viewMismatch = false;
531
+ if (ml) {
532
+ const [lfView, mlView] = results.map(result => result.end),
533
+ // Normalized Web Mercator world coordinates, clamped to its latitude limit.
534
+ project = ({ lat, lng }, zoom) => {
535
+ const sin = Math.sin(Math.max(-85.05112878, Math.min(85.05112878, lat)) * Math.PI / 180);
536
+ return [
537
+ (lng + 180) / 360,
538
+ 0.5 - Math.log((1 + sin) / (1 - sin)) / (4 * Math.PI),
539
+ ];
540
+ },
541
+ zoom = (lfView.zoom + mlView.zoom) / 2,
542
+ a = project(lfView, zoom),
543
+ b = project(mlView, zoom),
544
+ dx = ((a[0] - b[0] + 0.5) % 1 + 1) % 1 - 0.5,
545
+ dy = a[1] - b[1],
546
+ centerDelta = Math.hypot(dx, dy) * 256 * 2 ** zoom,
547
+ zoomDelta = Math.abs(lfView.zoom - mlView.zoom),
548
+ bearingDelta = Math.abs(((lfView.bearing - mlView.bearing + 180) % 360 + 360) % 360 - 180),
549
+ pitchDelta = Math.abs(lfView.pitch - mlView.pitch);
550
+ viewDelta = `${centerDelta.toFixed(1)}px / z ${zoomDelta.toFixed(2)}`;
551
+ viewMismatch = centerDelta > 3 || zoomDelta > 0.05 || bearingDelta > 0.1 || pitchDelta > 0.1;
552
+ }
553
+ const viewCell = row.insertCell();
554
+ viewCell.className = 'ml-only';
555
+ viewCell.classList.toggle('hidden', !ml);
556
+ viewCell.textContent = viewDelta || '';
557
+ viewCell.classList.toggle('bad', viewMismatch);
558
+ const spatialCell = row.insertCell();
559
+ spatialCell.className = 'ml-only';
560
+ spatialCell.classList.toggle('hidden', !ml);
561
+ spatialCell.textContent = ml ? `${Math.max(...results.map(r => r.spatialDelta)).toFixed(1)}px` : '';
562
+ spatialCell.classList.toggle('bad', results.some(r => r.spatialDelta > 3));
563
+ row.className = '';
564
+ running = false;
565
+ $('run').disabled = $('run-all').disabled = false;
566
+ return results;
567
+ };
568
+ // Expose the same cases/runner to browser automation and the interactive controls.
569
+ window.scenarios = scenarios;
570
+ window.runScenario = run;
571
+ $('run').onclick = () => run(scenarios[$('scenario').value]);
572
+ $('run-all').onclick = async () => { for (const s of scenarios) await run(s); };
573
+ $('clear').onclick = e => { e.preventDefault(); $('results').tBodies[0].replaceChildren(); $('results-panel').classList.add('hidden'); };
574
+ </script>
202
575
 
203
- </body>
576
+ <a href="https://github.com/Raruto/leaflet-rotate" class="view-on-github" style="position: fixed;bottom: 10px;left: calc(50% - 60px);z-index: 9999;"> <img alt="View on Github" src="https://raruto.github.io/img/view-on-github.png" title="View on Github" width="163"> </a>
577
+ </body>
204
578
 
205
- </html>
579
+ </html>