postnummer-app/frontend/src/main.ts
2023-02-13 01:42:40 +01:00

379 lines
13 KiB
TypeScript

import { Throttler } from "./Throttler";
import { setTopbarOffset, addToggleDropdownListener } from "./topbar";
import { Coordinate, Position, convertPixelsToCoordinate, convertCoordinateToPixels } from "./coordinates";
import { Size } from "./size";
import { Tooltip } from "./Tooltip";
import { loadReviews } from "./review";
const domSelect = <E extends Element>(query: string) => document.querySelector<E>(query);
const tooltip = new Tooltip(document.getElementById("tooltip")!);
type Square = {
x1: number,
y1: number,
x2: number,
y2: number,
};
type ZipCodeReverseResponse = {
nr: number | null;
navn: string;
visueltcenter: number[];
bbox: number[];
};
async function fetchZipCode({
longitude,
latitude,
}: Coordinate): Promise<ZipCodeReverseResponse> {
return fetch(
`https://api.dataforsyningen.dk/postnumre/reverse?x=${longitude}&y=${latitude}&landpostnumre`,
)
.then((request) => request.json())
.then((data) => {
let zipCode = parseInt(data.nr);
return {
...data,
nr: isNaN(zipCode) ? null : zipCode,
} as ZipCodeReverseResponse;
})
.catch(() => null as never);
}
let currentBoundary: Array<number> | null = null;
async function fetchAndDisplayZipCode(coords: Coordinate) {
if (currentBoundary &&
coords.longitude > currentBoundary[0] &&
coords.latitude > currentBoundary[1] &&
coords.longitude < currentBoundary[2] &&
coords.latitude < currentBoundary[3]
) return;
const response = await fetchZipCode(coords);
currentBoundary = response.bbox;
displayZipCode(
domSelect<HTMLParagraphElement>("#zip-code")!,
response.nr,
response.navn,
response.visueltcenter ? { longitude: response.visueltcenter[0], latitude: response.visueltcenter[1] } : null,
response.bbox ? { x1: response.bbox[0], y1: response.bbox[1], x2: response.bbox[2], y2: response.bbox[3] } : null,
);
}
function displayMousePosition(element: HTMLParagraphElement, mouse: Position) {
element.innerHTML = `Mouse position: <code>(${mouse.x}px, ${mouse.y}px)</code>`;
}
function displayCoords(element: HTMLParagraphElement, coords: Coordinate) {
const longitude = coords.longitude.toFixed(3);
const latitude = coords.latitude.toFixed(3);
element.innerHTML = `Coords: <code>${longitude}, ${latitude}</code>`;
}
function displayZipCode(
element: HTMLParagraphElement,
zipCode: number | null,
name: string | null,
center: Coordinate | null,
boundary: Square | null,
) {
element.innerHTML =
zipCode === null
? `Postnummer ikke fundet`
: `Postnummer: <code>${zipCode}</code>, ${name}`;
tooltip.setText(zipCode ? `<code>${zipCode}</code> ${name}` : "");
const dot = document.getElementById("dot")!;
const boundaryElem = document.getElementById("boundary")!;
if (!center || !boundary) {
dot.style.display = "none";
boundaryElem.style.display = "none";
return;
}
const mapImg = document.getElementById("map")!;
const mapSize: Size = {
width: mapImg.clientWidth,
height: mapImg.clientHeight,
};
// Draw dot
const position = convertCoordinateToPixels(center, mapSize);
const rect = document.getElementById("map")!.getBoundingClientRect();
dot.style.display = "block";
dot.style.left = `${position.x + rect.left}px`;
dot.style.top = `${position.y + rect.top + document.documentElement.scrollTop}px`;
// Draw boundary
const bottomleft = convertCoordinateToPixels({ longitude: boundary.x1, latitude: boundary.y1 }, mapSize);
const topright = convertCoordinateToPixels({ longitude: boundary.x2, latitude: boundary.y2 }, mapSize);
boundaryElem.style.display = "block";
boundaryElem.style.left = `${bottomleft.x + rect.left}px`;
boundaryElem.style.top = `${topright.y + rect.top + document.documentElement.scrollTop}px`;
boundaryElem.style.width = `${topright.x - bottomleft.x}px`;
boundaryElem.style.height = `${bottomleft.y - topright.y}px`;
}
type Component = {
children?: Component[],
render(): string,
hydrate?(update: (action?: () => Promise<any>) => Promise<void>): Promise<void>,
};
function makeReviewsPage(): Component {
return {
render: () => /*html*/`
<h2 id="reviews-title">Anmeldelser</h2>
<div id="reviews-container">${loadReviews()}</div>
`,
}
}
function makeSearchBar(updateZipCodeInfo: (zipCode: string) => Promise<void>): Component {
return {
render: () => /*html*/`
<form id="search-bar">
<input id="search-input" type="text" placeholder="Postnummer" maxlength="4">
<button id="search-button" type="submit">Search</button>
</form>
`,
async hydrate(update) {
const searchBar =
domSelect<HTMLFormElement>("#search-bar")!;
const searchInput =
domSelect<HTMLInputElement>("#search-input")!;
searchInput.addEventListener("keydown", (event) => {
if (event.key.length === 1 && !"0123456789".includes(event.key))
event.preventDefault();
});
searchBar.addEventListener("submit", async (event: Event) => {
event.preventDefault();
update(() => updateZipCodeInfo(searchInput.value));
});
},
};
}
type ZipCodeInfo = {
zipCode: number | null,
name: string | null,
center: Coordinate | null,
boundary: Square | null,
}
function makeInfoBox(info: ZipCodeInfo): Component {
const { zipCode, name, center, boundary } = info;
return {
render: () => /*html*/`
<div id="info">
<p id="zip-code">${info.zipCode === null ? "Postnummer ikke fundet" : `Postnummer: <code>${zipCode}</code>, ${name}`}</p>
<p id="mouse-position"></p>
<p id="coords"></p>
</div>
`,
async hydrate() {
tooltip.setText(zipCode ? `<code>${zipCode}</code> ${name}` : "");
const dot = document.getElementById("dot")!;
const boundaryElem = document.getElementById("boundary")!;
if (!center || !boundary) {
dot.style.display = "none";
boundaryElem.style.display = "none";
return;
}
const mapImg = document.getElementById("map")!;
const mapSize: Size = {
width: mapImg.clientWidth,
height: mapImg.clientHeight,
};
// Draw dot
const position = convertCoordinateToPixels(center, mapSize);
const rect = document.getElementById("map")!.getBoundingClientRect();
dot.style.display = "block";
dot.style.left = `${position.x + rect.left}px`;
dot.style.top = `${position.y + rect.top + document.documentElement.scrollTop}px`;
// Draw boundary
const bottomleft = convertCoordinateToPixels({ longitude: boundary.x1, latitude: boundary.y1 }, mapSize);
const topright = convertCoordinateToPixels({ longitude: boundary.x2, latitude: boundary.y2 }, mapSize);
boundaryElem.style.display = "block";
boundaryElem.style.left = `${bottomleft.x + rect.left}px`;
boundaryElem.style.top = `${topright.y + rect.top + document.documentElement.scrollTop}px`;
boundaryElem.style.width = `${topright.x - bottomleft.x}px`;
boundaryElem.style.height = `${bottomleft.y - topright.y}px`;
}
}
}
function makeMainPage(): Component {
const zipCodeInfo: ZipCodeInfo = {
zipCode: null,
name: null,
center: null,
boundary: null,
}
const searchBar = makeSearchBar(async (searchInputValue) => {
const inputValue = searchInputValue;
if (!/^\d+$/.test(inputValue)) return;
const data = await fetch(
`https://api.dataforsyningen.dk/postnumre?nr=${inputValue}`,
).then((response) => response.json())
zipCodeInfo.zipCode = data.length ? parseInt(data[0]["nr"]) : null;
zipCodeInfo.name = data.length ? data[0]["navn"] : null;
zipCodeInfo.center = data.length ? { longitude: data[0]["visueltcenter"][0], latitude: data[0]["visueltcenter"][1] } : null;
zipCodeInfo.boundary = data.length ? { x1: data[0]["bbox"][0], y1: data[0]["bbox"][1], x2: data[0]["bbox"][2], y2: data[0]["bbox"][3] } : null;
});
const infoBox = makeInfoBox(zipCodeInfo);
return {
children: [searchBar, infoBox],
render: () => /*html*/`
${searchBar.render()}
<img src="assets/map.jpg" id="map">
<div id="dot"></div>
<div id="boundary"></div>
`,
async hydrate() {
const mousePositionElement = domSelect<HTMLParagraphElement>("#mouse-position")!;
const coordsElement = domSelect<HTMLParagraphElement>("#coords")!;
const zipCodeElement = domSelect<HTMLParagraphElement>("#zip-code")!;
const mapImg = domSelect<HTMLImageElement>("#map")!;
const fetcher = new Throttler(200);
mapImg.addEventListener('mousemove', async (event: MouseEvent) => {
const mousePosition: Position = { x: event.offsetX, y: event.offsetY };
displayMousePosition(mousePositionElement, mousePosition);
const mapSize: Size = {
width: mapImg.clientWidth,
height: mapImg.clientHeight,
};
const coords = convertPixelsToCoordinate(mousePosition, mapSize);
displayCoords(coordsElement, coords);
fetcher.call(async () => await fetchAndDisplayZipCode(coords));
});
mapImg.addEventListener("mouseup", async (event: MouseEvent) => {
const mousePosition: Position = { x: event.offsetX, y: event.offsetY };
displayMousePosition(mousePositionElement, mousePosition);
const mapSize: Size = {
width: mapImg.clientWidth,
height: mapImg.clientHeight,
};
const coords = convertPixelsToCoordinate(mousePosition, mapSize);
displayCoords(coordsElement, coords);
fetcher.call(async () => await fetchAndDisplayZipCode(coords));
});
mapImg.addEventListener("mouseleave", (_event: MouseEvent) => {
displayZipCode(zipCodeElement, null, null, null, null);
});
},
};
}
function makeRouter(route: "main" | "reviews"): Component {
switch (route) {
case "main":
{
const mainPage = makeMainPage();
return {
children: [mainPage],
render: () => mainPage.render(),
};
}
case "reviews": {
const reviewsPage = makeReviewsPage();
return {
children: [reviewsPage],
render: () => reviewsPage.render(),
}
}
}
}
function makeTooltip(): Component {
return {
render: () => /*html*/`
<div id="tooltip"></div>
`,
}
}
function makeLayout(): Component {
let route: "main" | "reviews" = "main";
const router = makeRouter(route);
const tooltip = makeTooltip();
return {
children: [router, tooltip],
render: () => /**/`
<div id="topbar">
<h1>Postnummer App</h1>
<div class="spacer"></div>
<button id="dropdown-button">≡</button>
<div id="dropdown">
<button id="map-redirect">Kort</button>
<button id="reviews-redirect">Anmeldelser</button>
</div>
</div>
<main id="main">
${router.render()}
</main>
${tooltip.render()}
`,
async hydrate(update) {
setTopbarOffset();
addToggleDropdownListener();
document.getElementById("reviews-redirect")!.addEventListener('click', () => {
update(async () => route = "reviews");
})
document.getElementById("map-redirect")!.addEventListener('click', () => {
update(async () => route = "main");
})
},
}
}
function hydrateComponentAndChildren(component: Component, update: (action?: () => Promise<any>) => Promise<void>) {
component.hydrate && component.hydrate(update);
component.children?.forEach((child) => hydrateComponentAndChildren(child, update));
}
function renderComponent(element: HTMLElement, component: Component) {
element.innerHTML = component.render();
window.requestAnimationFrame(() => {
hydrateComponentAndChildren(component, async (action) => {
action && await action();
element.innerHTML = component.render();
});
});
}
function main() {
if (navigator.userAgent.match("Chrome")) {
location.href = "https://mozilla.org/firefox";
}
const bodyElement = document.querySelector<HTMLElement>("body")!;
renderComponent(bodyElement, makeLayout());
}
main();