Skip to content

Commit dc1d52e

Browse files
committed
wip
1 parent af14cef commit dc1d52e

13 files changed

+584
-12
lines changed

components/config.js

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export const MAPTILER_KEY = "";

components/geom-utils.js

+57
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
const earth = 6378*1000;
2+
const D2R = Math.PI/180;
3+
4+
export function great_circle_distance(item_from, item_to) {
5+
let lat1 = D2R*item_from["GPSLatitude"];
6+
let lon1 = D2R*item_from["GPSLongitude"];
7+
let lat2 = D2R*item_to["GPSLatitude"];
8+
let lon2 = D2R*item_to["GPSLongitude"];
9+
10+
let dlat = lat2-lat1;
11+
let dlon = lon2-lon1;
12+
let a = Math.sin(dlat/2)**2 + Math.cos(lat1)*Math.cos(lat2)*Math.sin(dlon/2)**2;
13+
return 2*earth*Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
14+
}
15+
16+
export function azimuth(item_from, item_to) {
17+
let lat1 = D2R*item_from["GPSLatitude"];
18+
let lon1 = D2R*item_from["GPSLongitude"];
19+
let lat2 = D2R*item_to["GPSLatitude"];
20+
let lon2 = D2R*item_to["GPSLongitude"];
21+
let dlat = lat2 - lat1;
22+
let dlon = lon2 - lon1;
23+
24+
return Math.atan2(
25+
Math.sin(dlon)*Math.cos(lat2),
26+
Math.cos(lat1)*Math.sin(lat2) - Math.sin(lat1)*Math.cos(lat2)*Math.cos(dlat)
27+
);
28+
}
29+
30+
export function angular_distance(item_from, item_to) {
31+
let lat1 = D2R*item_from["GPSLatitude"];
32+
let lon1 = D2R*item_from["GPSLongitude"];
33+
let lat2 = D2R*item_to["GPSLatitude"];
34+
let lon2 = D2R*item_to["GPSLongitude"];
35+
let dlon = lon2 - lon1;
36+
return Math.acos(
37+
Math.sin(lat1)*Math.sin(lat2) +
38+
Math.cos(lat1)*Math.cos(lat2)*Math.cos(dlon)
39+
);
40+
}
41+
42+
export function vertical_azimuth_1(item_from, item_to) {
43+
let a1 = item_from["GPSAltitude"];
44+
let a2 = item_to["GPSAltitude"];
45+
let d = great_circle_distance(item_from, item_to);
46+
return -Math.atan((a1-a2)/d);
47+
}
48+
49+
export function vertical_azimuth_2(item_from, item_to) {
50+
let phi = angular_distance(item_from, item_to);
51+
let a1 = item_from["GPSAltitude"];
52+
let a2 = item_to["GPSAltitude"];
53+
let A1 = a1 + earth;
54+
let A2 = a2 + earth;
55+
let D = Math.sqrt(A1**2 + A2**2 - 2*A1*A2*Math.cos(phi))
56+
return Math.acos((A2**2 - A1**2 - D**2)/(-2*A1*D)) - Math.PI/2;
57+
}

components/pano-app.css

+18
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
body {
2+
margin: 0;
3+
height: 100dvh;
4+
}
5+
6+
pano-app {
7+
display: flex;
8+
width: 100%;
9+
height: 100%;
10+
11+
@media (orientation: portrait) {
12+
flex-direction: column;
13+
}
14+
15+
@media (orientation: landscape) {
16+
flex-direction: row;
17+
}
18+
}

components/pano-app.js

+61
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import PanoMap from "./pano-map.js";
2+
import PanoScene from "./pano-scene.js";
3+
4+
5+
export default class PanoApp extends HTMLElement {
6+
#map = new PanoMap();
7+
#scene = new PanoScene();
8+
#items = [];
9+
10+
constructor() {
11+
super();
12+
13+
this.#map.addEventListener("pano-click", e => {
14+
this.show(e.detail.item, {center:false, popup:false});
15+
});
16+
17+
this.#map.addEventListener("pano-over", e => this.#highlight(e.detail.item));
18+
this.#map.addEventListener("pano-out", e => this.#highlight(null));
19+
}
20+
21+
connectedCallback() {
22+
this.replaceChildren(this.#map, this.#scene);
23+
this.#load();
24+
25+
window.addEventListener("resize", _ => this.#syncSize());
26+
}
27+
28+
show(item, options) {
29+
this.#map.activate(item, options);
30+
this.#scene.show(item, this.#items);
31+
}
32+
33+
#highlight(item) {
34+
this.#map.highlight(item);
35+
this.#scene.highlight(item);
36+
}
37+
38+
async #load() {
39+
let response = await fetch("data.json");
40+
this.#items = await response.json();
41+
this.#map.showItems(this.#items);
42+
this.#fromURL();
43+
}
44+
45+
#syncSize() {
46+
this.#scene.syncSize();
47+
this.#map.invalidateSize();
48+
}
49+
50+
#fromURL() {
51+
let str = location.hash.substring(1);
52+
if (!str) { return; }
53+
54+
let item = this.#items.filter(item => item["SourceFile"] == str)[0];
55+
if (!item) { return; }
56+
57+
this.show(item, {center:true, popup:true});
58+
}
59+
60+
}
61+
customElements.define("pano-app", PanoApp);

components/pano-icon.css

+26
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
pano-icon {
2+
display: block;
3+
position: relative;
4+
5+
.fov {
6+
position: absolute;
7+
left: 50%;
8+
top: 50%;
9+
translate: -50% -50%;
10+
pointer-events: none;
11+
path {
12+
fill: url(#fov);
13+
}
14+
}
15+
16+
.icon {
17+
position: relative;
18+
display: block;
19+
path {
20+
stroke: rgba(0, 0, 0, 0.75);
21+
fill: dodgerblue;
22+
}
23+
}
24+
25+
&.active .icon path { fill: orange; }
26+
}

components/pano-icon.js

+75
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
export const SIZE = 24;
2+
export const FOV_SIZE = 96;
3+
4+
5+
const SVGNS = "http://www.w3.org/2000/svg";
6+
const DEGRAD = Math.PI/180;
7+
8+
export default class PanoIcon extends HTMLElement {
9+
#fov = createFov();
10+
#icon = createIcon();
11+
12+
connectedCallback() {
13+
this.replaceChildren(this.#fov, this.#icon);
14+
}
15+
16+
drawFov(angle, fov) {
17+
drawAngle(this.#fov, angle, fov)
18+
}
19+
20+
hideFov() {
21+
this.drawFov(0, 0);
22+
}
23+
}
24+
customElements.define("pano-icon", PanoIcon);
25+
26+
function drawAngle(svg, angle, fov) {
27+
let path = svg.querySelector("path");
28+
const r = FOV_SIZE/2;
29+
angle -= 90; // canvas has 0 => right, we want 0 => top
30+
let a1 = DEGRAD * (angle - fov/2);
31+
let a2 = DEGRAD * (angle + fov/2);
32+
path.setAttribute("d", `
33+
M ${r} ${r}
34+
L ${r + r*Math.cos(a1)} ${r + r*Math.sin(a1)}
35+
A ${r} ${r} 0 0 1 ${r + r*Math.cos(a2)} ${r + r*Math.sin(a2)}
36+
`);
37+
}
38+
39+
function createFov() {
40+
let svg = document.createElementNS(SVGNS, "svg");
41+
svg.setAttribute("class", "fov");
42+
svg.setAttribute("width", FOV_SIZE);
43+
svg.setAttribute("height", FOV_SIZE);
44+
let defs = document.createElementNS(SVGNS, "defs");
45+
defs.innerHTML = `
46+
<radialGradient id="fov" gradientUnits="userSpaceOnUse">
47+
<stop offset="0%" stop-color="rgba(255 165 0 / 1)" />
48+
<stop offset="100%" stop-color="rgba(255 165 0 / 0)" />
49+
</radialGradient>
50+
`;
51+
let path = document.createElementNS(SVGNS, "path");
52+
svg.append(defs, path);
53+
return svg;
54+
}
55+
56+
export function createIcon() {
57+
let svg = document.createElementNS(SVGNS, "svg");
58+
svg.setAttribute("class", "icon");
59+
svg.setAttribute("width", SIZE);
60+
svg.setAttribute("height", SIZE);
61+
let path = document.createElementNS(SVGNS, "path");
62+
path.setAttribute("transform", "translate(1 1)");
63+
const R1 = SIZE/2 - 1;
64+
const R2 = R1 - 6;
65+
path.setAttribute("d", `
66+
M ${0} ${R1}
67+
A ${R1} ${R1} 0 1 1 ${2*R1} ${R1}
68+
A ${R1} ${R1} 0 1 1 ${0} ${R1}
69+
M ${R1-R2} ${R1}
70+
A ${R2} ${R2} 0 1 0 ${R1+R2} ${R1}
71+
A ${R2} ${R2} 0 1 0 ${R1-R2} ${R1}
72+
`);
73+
svg.append(path);
74+
return svg;
75+
}

components/pano-map.css

+13
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
pano-map {
2+
display: block;
3+
flex: 1 1 0;
4+
5+
.leaflet-popup-content a {
6+
font-size: 116.667%;
7+
font-weight: bold;
8+
color: dodgerblue;
9+
}
10+
11+
.marker-cluster-small, .marker-cluster-medium, .marker-cluster-large { background-color: rgba(0, 126, 255, 0.4); }
12+
.marker-cluster-small div, .marker-cluster-medium div, .marker-cluster-large div { background-color: rgb(57, 140, 204, 0.6); }
13+
}

components/pano-map.js

+133
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import PanoIcon, { SIZE as ICON_SIZE } from "./pano-icon.js";
2+
import { MAPTILER_KEY } from "./config.js";
3+
4+
5+
function addLayers(map) {
6+
let osm = L.tileLayer(`https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png`, {
7+
maxZoom: 19,
8+
attribution: `© <a href="http://www.openstreetmap.org/copyright">OpenStreetMap contributors</a>`
9+
});
10+
11+
let topo = L.tileLayer(`https://api.maptiler.com/maps/topo/{z}/{x}/{y}.png?key=${MAPTILER_KEY}`, {
12+
tileSize: 512,
13+
zoomOffset: -1,
14+
minZoom: 1,
15+
attribution: `© <a href="https://www.maptiler.com/copyright/">MapTiler</a>, <a href="https://www.openstreetmap.org/copyright">OpenStreetMap contributors</a>`
16+
});
17+
18+
let satellite = L.tileLayer(`https://api.maptiler.com/tiles/satellite-v2/{z}/{x}/{y}.jpg?key=${MAPTILER_KEY}`, {
19+
tileSize: 512,
20+
zoomOffset: -1,
21+
minZoom: 1,
22+
attribution: `© <a href="https://www.maptiler.com/copyright/">MapTiler</a>, <a href="https://www.openstreetmap.org/copyright">OpenStreetMap contributors</a>`
23+
});
24+
25+
let layers = {
26+
"Base + Terrain": topo,
27+
"OSM": osm,
28+
"Satellite": satellite
29+
};
30+
L.control.layers(layers).addTo(map);
31+
map.addLayer(topo);
32+
}
33+
34+
const dateFormat = new Intl.DateTimeFormat([], {dateStyle:"medium", timeStyle:"short"});
35+
36+
export default class PanoMap extends HTMLElement {
37+
#map;
38+
#markers = new Map();
39+
#panoIcons = new Map();
40+
41+
constructor() {
42+
super();
43+
}
44+
45+
connectedCallback() {
46+
const map = L.map(this);
47+
addLayers(map);
48+
this.#map = map;
49+
}
50+
51+
showItems(items) {
52+
this.#markers.clear();
53+
54+
let group = L.markerClusterGroup({showCoverageOnHover:false, animate:false, maxClusterRadius:40});
55+
items.map(item => this.#buildMarker(item)).forEach(m => group.addLayer(m));
56+
this.#map.addLayer(group);
57+
58+
let bounds = group.getBounds();
59+
if (bounds.isValid()) {
60+
this.#map.fitBounds(bounds);
61+
} else {
62+
this.#map.setView([0, 0], 2);
63+
}
64+
}
65+
66+
activate(item, options) {
67+
if (options.center) { this.#map.setView([item["GPSLatitude"], item["GPSLongitude"]], 17); }
68+
69+
for (let [i, panoIcon] of this.#panoIcons.entries()) {
70+
panoIcon.classList.toggle("active", i == item);
71+
}
72+
73+
let marker = this.#markers.get(item);
74+
if (!marker._icon) { marker.__parent.spiderfy(); }
75+
76+
if (options.popup) { marker.openPopup(); }
77+
}
78+
79+
highlight(item) {
80+
for (let [i, panoIcon] of this.#panoIcons.entries()) {
81+
panoIcon.classList.toggle("highlight", i == item);
82+
}
83+
}
84+
85+
syncSize() { // fixme resizeobserver
86+
this.#map.invalidateSize();
87+
}
88+
89+
#dispatch(type, item) {
90+
let event = new CustomEvent(type, {detail:{item}});
91+
this.dispatchEvent(event);
92+
}
93+
94+
#buildPopup(item) {
95+
let frag = document.createDocumentFragment();
96+
97+
let name = document.createElement("a");
98+
let url = new URL(location.href);
99+
url.hash = item["SourceFile"];
100+
name.href = url.href;
101+
name.textContent = item["ImageDescription"] || "n/a";
102+
name.addEventListener("click", _ => this.#dispatch("pano-click", item));
103+
104+
let date = document.createElement("div");
105+
date.append(dateFormat.format(new Date(item["CreateDate"] * 1000)));
106+
107+
let altitude = document.createElement("div");
108+
altitude.append(`Altitude: ${item["GPSAltitude"]} m`);
109+
110+
frag.append(name, date, altitude);
111+
return frag;
112+
}
113+
114+
#buildMarker(item) {
115+
let panoIcon = new PanoIcon();
116+
let iconSize = [ICON_SIZE, ICON_SIZE];
117+
let popupAnchor = [0, -ICON_SIZE/2];
118+
let icon = L.divIcon({html:panoIcon, popupAnchor, iconSize, className:""});
119+
let marker = L.marker([item["GPSLatitude"], item["GPSLongitude"]], {title:item["ImageDescription"] || "", icon});
120+
121+
item.panoIcon = panoIcon;
122+
marker.bindPopup(() => this.#buildPopup(item));
123+
124+
panoIcon.addEventListener("mouseenter", _ => this.#dispatch("pano-over", item));
125+
panoIcon.addEventListener("mouseleave", _ => this.#dispatch("pano-out", item));
126+
127+
this.#markers.set(item, marker);
128+
this.#panoIcons.set(item, panoIcon);
129+
130+
return marker;
131+
}
132+
}
133+
customElements.define("pano-map", PanoMap);

0 commit comments

Comments
 (0)