Skip to content

Commit bd4d40e

Browse files
committed
componentise
1 parent 98bc107 commit bd4d40e

File tree

9 files changed

+326
-295
lines changed

9 files changed

+326
-295
lines changed

index.html

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,17 +9,17 @@
99
<body>
1010

1111

12-
<form>
12+
<form is="cycloops-form">
1313
<textarea name="message" placeholder="What's happening here" rows="1"></textarea>
1414

1515
<button>
1616
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="currentColor"><path d="M440-400h80v-120h120v-80H520v-120h-80v120H320v80h120v120Zm40 214q122-112 181-203.5T720-552q0-109-69.5-178.5T480-800q-101 0-170.5 69.5T240-552q0 71 59 162.5T480-186Zm0 106Q319-217 239.5-334.5T160-552q0-150 96.5-239T480-880q127 0 223.5 89T800-552q0 100-79.5 217.5T480-80Zm0-480Z"/></svg>
1717
</button>
1818
</form>
1919

20-
<ol></ol>
20+
<ol is="cycloops-list"></ol>
2121

22-
<div id="map"></div>
22+
<div is="cycloops-map"></div>
2323

2424
<script type="module" src="/src/main.ts"></script>
2525
</body>

src/components/form.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { db } from "../db";
2+
3+
export class CycloopsForm extends HTMLFormElement {
4+
connectedCallback() {
5+
this.addEventListener("submit", this.submitHandler);
6+
7+
// resize any text areas to fit content
8+
for (const textarea of this.querySelectorAll("textarea")) {
9+
textarea.addEventListener("input", () => {
10+
const padding = 12;
11+
12+
textarea.style.height = "auto";
13+
textarea.style.height = `${textarea.scrollHeight - padding * 2}px`;
14+
});
15+
}
16+
}
17+
18+
async submitHandler(e: Event) {
19+
e.preventDefault();
20+
21+
const data = new FormData(this);
22+
const text = data.get("message") as string;
23+
const time = Date.now();
24+
25+
const [lat, lon] = await new Promise<[number, number]>(
26+
(resolve, reject) => {
27+
navigator.geolocation.getCurrentPosition(
28+
(position) => {
29+
console.log(position.coords);
30+
resolve([position.coords.latitude, position.coords.longitude]);
31+
},
32+
() => {
33+
resolve([0, 0]);
34+
}
35+
);
36+
}
37+
);
38+
39+
await db.notes.add({
40+
time,
41+
text,
42+
lat,
43+
lon,
44+
});
45+
46+
this.reset();
47+
}
48+
}

src/components/list.ts

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { effect } from "@preact/signals-core";
2+
import type { Note } from "../db";
3+
import { notes, visible, focus } from "../state";
4+
import { distance } from "@turf/turf";
5+
import { formatDistanceStrict } from "date-fns/formatDistanceStrict";
6+
7+
const number = new Intl.NumberFormat(undefined, {
8+
style: "unit",
9+
unit: "kilometer",
10+
maximumSignificantDigits: 3,
11+
});
12+
13+
document.addEventListener("focusin", (e) => {
14+
const el = e.target as HTMLLIElement;
15+
if (el.dataset.id) {
16+
focus.value = parseFloat(el.dataset.id);
17+
}
18+
});
19+
20+
document.addEventListener("focusout", () => {
21+
focus.value = -1;
22+
});
23+
24+
export class CycloopsList extends HTMLOListElement {
25+
observer: IntersectionObserver;
26+
27+
constructor() {
28+
super();
29+
30+
// track which notes are visible on screen
31+
this.observer = new IntersectionObserver((entries) => {
32+
const next = new Set(visible.value);
33+
34+
for (const entry of entries) {
35+
const id = (entry.target as HTMLLIElement).dataset.id;
36+
37+
if (id) {
38+
if (entry.isIntersecting) {
39+
next.add(parseFloat(id));
40+
} else {
41+
next.delete(parseFloat(id));
42+
}
43+
}
44+
}
45+
46+
visible.value = next;
47+
});
48+
49+
this.addEventListener("click", (e) => {
50+
const el = e.target;
51+
if (el instanceof HTMLElement && el.dataset.id) {
52+
el.focus();
53+
}
54+
});
55+
}
56+
57+
connectedCallback() {
58+
const posts = this;
59+
const observer = this.observer;
60+
61+
console.log("connected");
62+
63+
// render the list of notes
64+
effect(() => {
65+
for (const li of posts.querySelectorAll("li")) {
66+
observer.unobserve(li);
67+
}
68+
69+
posts.innerHTML = "";
70+
71+
let last: Note | null = null;
72+
73+
for (const note of notes.value) {
74+
if (last) {
75+
const far = distance([last.lon, last.lat], [note.lon, note.lat], {
76+
units: "kilometers",
77+
});
78+
const formatted = far > 1 ? number.format(far) : "<1 km";
79+
80+
const div = document.createElement("li");
81+
div.className = "delta";
82+
div.innerText = `${formatDistanceStrict(
83+
last.time,
84+
note.time
85+
)} · ${formatted}`;
86+
87+
posts.appendChild(div);
88+
}
89+
90+
const li = document.createElement("li");
91+
li.className = "note";
92+
93+
li.innerText = note.text;
94+
li.dataset.id = String(note.id);
95+
96+
li.id = `note-${note.id}`;
97+
li.tabIndex = 0;
98+
99+
posts.appendChild(li);
100+
101+
last = note;
102+
}
103+
104+
for (const li of posts.querySelectorAll("li")) {
105+
observer.observe(li);
106+
}
107+
});
108+
}
109+
}

src/components/map.ts

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import { computed, effect } from "@preact/signals-core";
2+
import { bbox } from "@turf/turf";
3+
import maplibregl, { GeoJSONSource } from "maplibre-gl";
4+
import "maplibre-gl/dist/maplibre-gl.css";
5+
import { notes, visible, focus } from "../state";
6+
7+
type Locations = GeoJSON.FeatureCollection<
8+
GeoJSON.Point,
9+
{ id: number; text: string }
10+
>;
11+
12+
export const noteLocations = computed<Locations>(() => ({
13+
type: "FeatureCollection",
14+
features: notes.value.map((note) => ({
15+
type: "Feature",
16+
properties: {
17+
id: note.id,
18+
text: note.text,
19+
},
20+
geometry: {
21+
type: "Point",
22+
coordinates: [note.lon, note.lat],
23+
},
24+
})),
25+
}));
26+
27+
export const visibleLocations = computed<Locations>(() => {
28+
const all = noteLocations.value;
29+
30+
const features = all.features.filter((p) =>
31+
visible.value.has(p.properties.id)
32+
);
33+
34+
return { ...all, features };
35+
});
36+
37+
export const focusLocation = computed<Locations>(() => {
38+
const all = noteLocations.value;
39+
40+
const features = all.features.filter((p) => p.properties.id === focus.value);
41+
42+
return { ...all, features };
43+
});
44+
45+
export class CycloopsMap extends HTMLDivElement {
46+
connectedCallback() {
47+
console.log("connected map", this);
48+
49+
this.style.position = "fixed";
50+
this.style.left =
51+
this.style.top =
52+
this.style.right =
53+
this.style.bottom =
54+
"0";
55+
this.style.zIndex = "-1";
56+
57+
const map = new maplibregl.Map({
58+
container: this,
59+
style: "https://tiles.openfreemap.org/styles/positron",
60+
center: [0, 0],
61+
zoom: 1,
62+
interactive: false,
63+
});
64+
65+
map.on("load", () => {
66+
// add a blank feature collection
67+
map.addSource("notes", {
68+
type: "geojson",
69+
data: {
70+
type: "FeatureCollection",
71+
features: [],
72+
},
73+
});
74+
75+
map.addLayer({
76+
id: "notes_markers",
77+
type: "circle",
78+
source: "notes",
79+
paint: {
80+
"circle-radius": 5,
81+
"circle-color": "#f08",
82+
},
83+
});
84+
85+
map.addSource("focus", {
86+
type: "geojson",
87+
data: {
88+
type: "FeatureCollection",
89+
features: [],
90+
},
91+
});
92+
93+
map.addLayer({
94+
id: "focus_markers",
95+
type: "circle",
96+
source: "focus",
97+
paint: {
98+
"circle-radius": 10,
99+
"circle-color": "#08f",
100+
},
101+
});
102+
103+
effect(() => {
104+
if (visibleLocations.value.features.length === 0) return;
105+
106+
map.getSource<GeoJSONSource>("notes")?.setData(visibleLocations.value);
107+
108+
const bounds = bbox(visibleLocations.value) as [
109+
number,
110+
number,
111+
number,
112+
number
113+
];
114+
115+
map.fitBounds(bounds, { padding: 100, speed: 5 });
116+
});
117+
118+
effect(() => {
119+
map.getSource<GeoJSONSource>("focus")?.setData(focusLocation.value);
120+
});
121+
});
122+
}
123+
}

src/counter.ts

Lines changed: 0 additions & 9 deletions
This file was deleted.

src/db.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import Dexie, { liveQuery, type EntityTable } from "dexie";
2+
import { notes } from "./state";
3+
4+
export interface Note {
5+
id: number;
6+
time: number;
7+
text: string;
8+
lat: number;
9+
lon: number;
10+
}
11+
12+
export const db = new Dexie("Cycleoops") as Dexie & {
13+
notes: EntityTable<Note, "id">;
14+
};
15+
16+
// Declare tables, IDs and indexes
17+
db.version(1).stores({
18+
notes: "++id, time, text, lat, lon",
19+
});
20+
21+
// populate note state from dexie
22+
const query = liveQuery(() => db.notes.orderBy("time").reverse().toArray());
23+
query.subscribe({
24+
next(value) {
25+
notes.value = value;
26+
},
27+
error(err) {
28+
console.error(err);
29+
},
30+
});

0 commit comments

Comments
 (0)