aboutsummaryrefslogtreecommitdiffstats
path: root/app/main.js
diff options
context:
space:
mode:
authorPetri Hienonen <petri.hienonen@gmail.com>2025-10-29 15:18:30 +0200
committerPetri Hienonen <petri.hienonen@gmail.com>2025-11-03 10:54:48 +0200
commitb03ee7032b2ea2d4d22ab7ec1346b7c9331cfc17 (patch)
treeefc0ce6823ab8611d9c6a0bf27ecdbd124638b73 /app/main.js
downloadhousing-b03ee7032b2ea2d4d22ab7ec1346b7c9331cfc17.tar.zst
Initial commit
Diffstat (limited to 'app/main.js')
-rw-r--r--app/main.js745
1 files changed, 745 insertions, 0 deletions
diff --git a/app/main.js b/app/main.js
new file mode 100644
index 0000000..01ceef1
--- /dev/null
+++ b/app/main.js
@@ -0,0 +1,745 @@
+import { Dom } from "dom";
+import { ColorParameter, MapEl } from "map";
+import {
+ DataProvider,
+ District,
+ Filters,
+ House,
+ ScoringEngine,
+ TrainStation,
+ TrainTracks,
+ Weights,
+} from "models";
+
+export class App {
+ /** @type {House[]} */
+ #houses = [];
+ /** @type {TrainTracks[]} */
+ #trainTracks = [];
+ /** @type {TrainStation[]} */
+ #trainStations = [];
+ /** @type {House[]} */
+ #filtered = [];
+ /** @type {Filters} */
+ #filters = new Filters();
+ /** @type {Weights} */
+ #weights = new Weights();
+ /** @type {District[]} */
+ #districts = [];
+ /** @type {MapEl|null} */
+ #map = null;
+ /** @type {HTMLElement} */
+ #stats;
+ /** @type {HTMLElement} */
+ #controls;
+ /** @type {HTMLDialogElement|null} */
+ #modal = null;
+ /** @type {number | null} */
+ #modalTimer = null;
+ /** @type {boolean} */
+ #persistent = false;
+ /** @type {string} */
+ #colorParameter = ColorParameter.price;
+
+ constructor() {
+ // Set up main layout container
+ Object.assign(document.body.style, {
+ display: "flex",
+ flexDirection: "column",
+ fontFamily: "Roboto Mono",
+ height: "100vh",
+ margin: "0",
+ });
+
+ const loading = App.createLoading();
+
+ // Create main content container
+ const mainContainer = Dom.div({
+ styles: {
+ display: "flex",
+ flex: "1",
+ overflow: "hidden",
+ },
+ });
+
+ // Create map container
+ const mapContainer = Dom.div({
+ styles: {
+ display: "flex",
+ flex: "1",
+ flexDirection: "column",
+ minWidth: "0", // Prevents flex overflow
+ },
+ });
+
+ const stats = Dom.div({
+ styles: {
+ background: "#fff",
+ borderTop: "1px solid #ddd",
+ flexShrink: "0",
+ fontSize: "0.95rem",
+ padding: "0.75rem 1rem",
+ },
+ });
+
+ const controls = App.buildControls(
+ this.#filters,
+ this.#weights,
+ () => this.#applyFilters(),
+ (key, value) => {
+ if (key in this.#weights) {
+ this.#weights[/** @type {keyof Weights} */ (key)] = value;
+ }
+ App.recalculateScores(this.#houses, this.#weights);
+ this.#updateMapHouseColors();
+ this.#updateStats();
+ },
+ (param) => {
+ this.#colorParameter = param;
+ this.#updateMapHouseColors();
+ },
+ );
+
+ // Build layout hierarchy
+ mainContainer.append(controls, mapContainer);
+ document.body.append(loading, mainContainer);
+
+ this.#stats = stats;
+ this.#controls = controls;
+
+ // Initialize map
+ this.#map = new MapEl({
+ onHouseClick: (houseId, persistent) => this.#showHouseModal(houseId, persistent),
+ onHouseHover: (houseId, hide) => {
+ if (hide) {
+ this.#hideModal();
+ } else {
+ this.#showHouseModal(houseId, false);
+ }
+ },
+ });
+ mapContainer.append(this.#map.initializeMap(), stats);
+ this.#loadData(loading);
+ }
+
+ /**
+ * Create loading indicator
+ * @returns {HTMLElement}
+ */
+ static createLoading() {
+ return Dom.div({
+ styles: {
+ background: "white",
+ borderRadius: "8px",
+ boxShadow: "0 2px 10px rgba(0,0,0,0.1)",
+ color: "#555",
+ fontSize: "1.2rem",
+ left: "50%",
+ padding: "2rem",
+ position: "absolute",
+ textAlign: "center",
+ top: "50%",
+ transform: "translate(-50%, -50%)",
+ zIndex: "1000",
+ },
+ textContent: "Loading data…",
+ });
+ }
+
+ /**
+ * Build controls container
+ * @param {Filters} filters
+ * @param {Weights} weights
+ * @param {() => void} onFilterChange
+ * @param {(key: string, value: number) => void} onWeightChange
+ * @param {(param: string) => void} onColorChange
+ * @returns {HTMLElement}
+ */
+ static buildControls(filters, weights, onFilterChange, onWeightChange, onColorChange) {
+ const controls = Dom.div({
+ styles: {
+ background: "#fff",
+ borderRight: "1px solid #ddd",
+ display: "flex",
+ flexDirection: "column",
+ flexShrink: "0",
+ gap: "1rem",
+ overflowY: "auto",
+ padding: "1rem",
+ width: "300px",
+ },
+ });
+
+ // Color parameter section
+ const colorSection = Dom.div({
+ styles: {
+ borderBottom: "1px solid #eee",
+ paddingBottom: "1rem",
+ },
+ });
+
+ const colorTitle = Dom.heading(3, {
+ styles: {
+ color: "#333",
+ fontSize: "1.1rem",
+ margin: "0 0 1rem 0",
+ },
+ textContent: "Map Colors",
+ });
+
+ const colorGroup = Dom.div({
+ styles: { display: "flex", flexDirection: "column" },
+ });
+
+ const colorLabel = Dom.label({
+ for: "color-parameter",
+ styles: { fontSize: "0.85rem", fontWeight: "bold", marginBottom: "0.25rem" },
+ textContent: "Color houses by",
+ });
+
+ const colorSelect = Dom.select({
+ id: "color-parameter",
+ onChange: (e) => {
+ const target = /** @type {HTMLSelectElement} */ (e.target);
+ onColorChange(target.value);
+ },
+ styles: {
+ border: "1px solid #ddd",
+ borderRadius: "4px",
+ fontSize: "0.9rem",
+ padding: "0.5rem",
+ },
+ });
+
+ colorSelect.append(
+ Dom.option(ColorParameter.price, "Price"),
+ Dom.option(ColorParameter.score, "Score"),
+ Dom.option(ColorParameter.year, "Construction Year"),
+ Dom.option(ColorParameter.area, "Living Area"),
+ );
+
+ colorGroup.append(colorLabel, colorSelect);
+ colorSection.append(colorTitle, colorGroup);
+ controls.appendChild(colorSection);
+
+ // Filter section
+ const filterSection = Dom.div({
+ styles: {
+ borderBottom: "1px solid #eee",
+ paddingBottom: "1rem",
+ },
+ });
+
+ const filterTitle = Dom.heading(3, {
+ styles: {
+ color: "#333",
+ fontSize: "1.1rem",
+ margin: "0 0 1rem 0",
+ },
+ textContent: "Filters",
+ });
+
+ filterSection.appendChild(filterTitle);
+
+ // Price filters in a row
+ const priceRow = Dom.div({
+ styles: {
+ display: "flex",
+ gap: "0.5rem",
+ },
+ });
+
+ const minPriceFilter = App.addNumberFilter("min-price", "Min price (€)", (v) => {
+ filters.minPrice = v ?? 0;
+ onFilterChange();
+ });
+
+ const maxPriceFilter = App.addNumberFilter("max-price", "Max price (€)", (v) => {
+ filters.maxPrice = v ?? Number.POSITIVE_INFINITY;
+ onFilterChange();
+ });
+
+ priceRow.append(minPriceFilter, maxPriceFilter);
+
+ const yearFilter = App.addNumberFilter("min-year", "Min year", (v) => {
+ filters.minYear = v ?? 0;
+ onFilterChange();
+ });
+
+ const areaFilter = App.addNumberFilter("min-area", "Min area (m²)", (v) => {
+ filters.minArea = v ?? 0;
+ onFilterChange();
+ });
+
+ // District multi-select
+ const districtGroup = Dom.div({
+ styles: { display: "flex", flexDirection: "column" },
+ });
+
+ const districtLabel = Dom.label({
+ for: "district-select",
+ styles: { fontSize: "0.85rem", fontWeight: "bold", marginBottom: "0.25rem" },
+ textContent: "Districts",
+ });
+
+ const districtSelect = Dom.select({
+ attributes: { multiple: "true" },
+ id: "district-select",
+ onChange: (e) => {
+ const target = /** @type {HTMLSelectElement} */ (e.target);
+ const selectedOptions = Array.from(target.selectedOptions).map((opt) => opt.value);
+ filters.districts = selectedOptions;
+ onFilterChange();
+ },
+ styles: {
+ border: "1px solid #ddd",
+ borderRadius: "4px",
+ minHeight: "120px",
+ padding: "0.5rem",
+ },
+ });
+
+ districtGroup.append(districtLabel, districtSelect);
+
+ filterSection.append(priceRow, yearFilter, areaFilter, districtGroup);
+ controls.appendChild(filterSection);
+
+ // Weights section
+ const weightsSection = Dom.div({
+ styles: {
+ borderBottom: "1px solid #eee",
+ paddingBottom: "1rem",
+ },
+ });
+
+ const weightsTitle = Dom.heading(3, {
+ styles: {
+ color: "#333",
+ fontSize: "1.1rem",
+ margin: "0 0 1rem 0",
+ },
+ textContent: "Weights",
+ });
+
+ weightsSection.appendChild(weightsTitle);
+
+ // Create weight sliders
+ const weightSliders = [
+ App.addSlider("w-price", "Price weight", "price", weights.price, onWeightChange),
+ App.addSlider(
+ "w-market",
+ "Market distance",
+ "distanceMarket",
+ weights.distanceMarket,
+ onWeightChange,
+ ),
+ App.addSlider(
+ "w-school",
+ "School distance",
+ "distanceSchool",
+ weights.distanceSchool,
+ onWeightChange,
+ ),
+ App.addSlider("w-crime", "Crime rate", "crimeRate", weights.crimeRate, onWeightChange),
+ App.addSlider("w-safety", "Safety index", "safety", weights.safety, onWeightChange),
+ App.addSlider("w-students", "S2 students", "s2Students", weights.s2Students, onWeightChange),
+ App.addSlider(
+ "w-railway",
+ "Railway distance",
+ "distanceRailway",
+ weights.distanceRailway,
+ onWeightChange,
+ ),
+ App.addSlider(
+ "w-year",
+ "Construction year",
+ "constructionYear",
+ weights.constructionYear,
+ onWeightChange,
+ ),
+ ];
+
+ weightsSection.append(...weightSliders);
+ controls.appendChild(weightsSection);
+
+ return controls;
+ }
+
+ /**
+ * Create a number filter input
+ * @param {string} id
+ * @param {string} labelText
+ * @param {(value: number | null) => void} onChange
+ * @returns {HTMLElement}
+ */
+ static addNumberFilter(id, labelText, onChange) {
+ const group = Dom.div({
+ styles: { display: "flex", flexDirection: "column", marginBottom: "0.75rem" },
+ });
+
+ const label = Dom.label({
+ for: id,
+ styles: { fontSize: "0.85rem", fontWeight: "bold", marginBottom: "0.25rem" },
+ textContent: labelText,
+ });
+
+ const input = Dom.input({
+ id,
+ onInput: /** @param {Event} e */ (e) => {
+ const target = /** @type {HTMLInputElement} */ (e.target);
+ const raw = target.value.trim();
+ onChange(raw === "" ? null : Number(raw));
+ },
+ placeholder: "any",
+ styles: {
+ border: "1px solid #ddd",
+ borderRadius: "4px",
+ fontSize: "0.9rem",
+ padding: "0.5rem",
+ },
+ type: "number",
+ });
+
+ group.append(label, input);
+ return group;
+ }
+
+ /**
+ * Create a weight slider
+ * @param {string} id
+ * @param {string} labelText
+ * @param {string} weightKey
+ * @param {number} initialValue
+ * @param {(key: string, value: number) => void} onChange
+ * @returns {HTMLElement}
+ */
+ static addSlider(id, labelText, weightKey, initialValue, onChange) {
+ const group = Dom.div({
+ styles: { display: "flex", flexDirection: "column", marginBottom: "1rem" },
+ });
+
+ const label = Dom.label({ for: id });
+ const output = Dom.span({
+ id: `${id}-value`,
+ styles: { color: "#0066cc", fontSize: "0.85rem", fontWeight: "bold", textAlign: "center" },
+ textContent: initialValue.toFixed(1),
+ });
+
+ const labelTextSpan = Dom.span({
+ styles: { fontSize: "0.85rem" },
+ textContent: labelText,
+ });
+
+ label.append(labelTextSpan, " ", output);
+
+ const slider = Dom.input({
+ attributes: { max: "1", min: "0", step: "0.1", value: initialValue.toString() },
+ id,
+ onInput: /** @param {Event} e */ (e) => {
+ const target = /** @type {HTMLInputElement} */ (e.target);
+ const val = Number(target.value);
+ output.textContent = val.toFixed(1);
+ onChange(weightKey, val);
+ },
+ styles: {
+ margin: "0.5rem 0",
+ width: "100%",
+ },
+ type: "range",
+ });
+
+ group.append(label, slider);
+ return group;
+ }
+
+ /**
+ * Show modal with house details
+ * @param {string} houseId
+ * @param {boolean} persistent
+ */
+ #showHouseModal(houseId, persistent) {
+ const house = this.#houses.find((h) => h.id === houseId);
+ if (!house) return;
+
+ this.#persistent = persistent;
+ if (this.#map) {
+ this.#map.setModalPersistence(persistent);
+ }
+
+ // Remove existing modal
+ this.#modal?.remove();
+
+ // Create new modal
+ this.#modal = Dom.buildModal(() => this.#hideModal());
+ Object.assign(this.#modal.style, {
+ left: "auto",
+ maxHeight: "80vh",
+ maxWidth: "400px",
+ right: "20px",
+ top: "50%",
+ transform: "translateY(-50%)",
+ width: "90%",
+ });
+
+ // Add hover grace period listeners
+ this.#modal.addEventListener("mouseenter", () => {
+ clearTimeout(this.#modalTimer);
+ if (this.#map) {
+ this.#map.clearModalTimer();
+ }
+ });
+
+ this.#modal.addEventListener("mouseleave", () => {
+ if (!this.#persistent) {
+ this.#modalTimer = setTimeout(() => this.#hideModal(), 200);
+ }
+ });
+
+ // Build modal content
+ const content = this.#buildHouseModalContent(house);
+ this.#modal.appendChild(content);
+ document.body.appendChild(this.#modal);
+
+ if (persistent) {
+ this.#modal.showModal();
+ } else {
+ this.#modal.show();
+ }
+ }
+
+ /**
+ * Hide the modal
+ */
+ #hideModal() {
+ this.#modal?.close();
+ this.#modal?.remove();
+ this.#modal = null;
+ this.#persistent = false;
+ clearTimeout(this.#modalTimer);
+ if (this.#map) {
+ this.#map.setModalPersistence(false);
+ this.#map.clearModalTimer();
+ }
+ }
+
+ /**
+ * Build modal content for a house
+ * @param {House} house
+ * @returns {DocumentFragment}
+ */
+ #buildHouseModalContent(house) {
+ const frag = document.createDocumentFragment();
+
+ /* Header */
+ const header = Dom.div({
+ styles: {
+ alignItems: "center",
+ display: "flex",
+ justifyContent: "space-between",
+ marginBottom: "20px",
+ },
+ });
+ const title = Dom.heading(2, {
+ styles: { color: "#333", fontSize: "20px", margin: "0" },
+ textContent: house.address,
+ });
+ const score = Dom.span({
+ styles: {
+ background: "#e8f5e9",
+ borderRadius: "4px",
+ color: "#2e7d32",
+ fontSize: "16px",
+ fontWeight: "bold",
+ padding: "4px 8px",
+ },
+ textContent: `Score: ${house.scores.current}`,
+ });
+ Dom.appendChildren(header, [title, score]);
+ frag.appendChild(header);
+
+ /* Details grid */
+ const grid = Dom.div({
+ styles: {
+ display: "grid",
+ gap: "15px",
+ gridTemplateColumns: "repeat(2,1fr)",
+ marginBottom: "20px",
+ },
+ });
+ const details = [
+ { label: "Price", value: `€${house.price.toLocaleString()}` },
+ { label: "Building Type", value: house.buildingType },
+ { label: "Construction Year", value: house.constructionYear?.toString() ?? "N/A" },
+ { label: "Living Area", value: `${house.livingArea} m²` },
+ { label: "District", value: house.district },
+ { label: "Rooms", value: house.rooms?.toString() ?? "N/A" },
+ ];
+ for (const { label, value } of details) {
+ const item = Dom.div({
+ children: [
+ Dom.div({
+ styles: { fontSize: "14px", fontWeight: "bold", marginBottom: "4px" },
+ textContent: label,
+ }),
+ Dom.div({ styles: { color: "#333", fontSize: "14px" }, textContent: value }),
+ ],
+ });
+ grid.appendChild(item);
+ }
+ frag.appendChild(grid);
+
+ /* Description */
+ const descSect = Dom.div({ styles: { marginBottom: "20px" } });
+ const descTitle = Dom.div({
+ styles: { fontSize: "14px", fontWeight: "bold", marginBottom: "5px" },
+ textContent: "Description",
+ });
+ const descText = Dom.p({
+ styles: { color: "#333", fontSize: "14px", lineHeight: "1.4", marginTop: "5px" },
+ textContent: house.description || "No description available.",
+ });
+ Dom.appendChildren(descSect, [descTitle, descText]);
+ frag.appendChild(descSect);
+
+ /* Images */
+ if (house.images?.length) {
+ const imgSect = Dom.div({ styles: { marginBottom: "20px" } });
+ const imgTitle = Dom.div({
+ styles: { fontSize: "14px", fontWeight: "bold", marginBottom: "10px" },
+ textContent: "Images",
+ });
+ const imgCont = Dom.div({
+ styles: { display: "flex", gap: "10px", overflowX: "auto", paddingBottom: "5px" },
+ });
+ for (const src of house.images.slice(0, 3)) {
+ imgCont.appendChild(
+ Dom.img({
+ attributes: { loading: "lazy" },
+ src,
+ styles: { borderRadius: "4px", flexShrink: "0", height: "100px" },
+ }),
+ );
+ }
+ Dom.appendChildren(imgSect, [imgTitle, imgCont]);
+ frag.appendChild(imgSect);
+ }
+
+ return frag;
+ }
+
+ /**
+ * Load data and initialize application
+ * @param {HTMLElement} loading
+ */
+ async #loadData(loading) {
+ try {
+ const [districts, houses, trainStations, trainTracks, coastLine, mainRoads] =
+ await Promise.all([
+ DataProvider.getDistricts(),
+ DataProvider.getHouses(),
+ DataProvider.getTrainStations(),
+ DataProvider.getTrainTracks(),
+ DataProvider.getCoastline(),
+ DataProvider.getMainRoads(),
+ ]);
+
+ this.#districts = districts;
+ this.#houses = houses;
+ this.#trainStations = trainStations;
+ this.#trainTracks = trainTracks;
+
+ this.#filtered = houses.slice();
+
+ if (this.#map) {
+ this.#map.setDistricts(districts);
+ this.#map.setTrainData(trainStations, trainTracks);
+ this.#map.setHouses(houses, this.#colorParameter);
+ this.#map.setMapData(coastLine, mainRoads);
+ }
+
+ // Populate district multi-select
+ const districtOptions = App.renderDistrictOptions(this.#districts, this.#houses);
+ const districtSelect = this.#controls.querySelector("#district-select");
+ if (districtSelect) {
+ districtSelect.append(...districtOptions);
+ }
+
+ this.#updateStats();
+ } finally {
+ loading.remove();
+ }
+ }
+
+ /**
+ * Update house colors on map
+ */
+ #updateMapHouseColors() {
+ if (this.#map) {
+ this.#map.setColorParameter(this.#colorParameter);
+ }
+ }
+
+ /**
+ * Render district options for multi-select
+ * @param {District[]} _districts
+ * @param {House[]} houses
+ * @returns {HTMLOptionElement[]}
+ */
+ static renderDistrictOptions(_districts, houses) {
+ // Get unique districts from houses (they might have districts not in the district polygons)
+ const houseDistricts = [...new Set(houses.map((h) => h.district).filter((d) => d))].sort();
+ return houseDistricts.map((districtName) => Dom.option(districtName, districtName));
+ }
+
+ #applyFilters() {
+ this.#filtered = App.applyFilters(this.#houses, this.#filters);
+
+ // Update map with filtered houses
+ if (this.#map) {
+ const filteredIds = this.#filtered.map((h) => h.id);
+ this.#map.updateHouseVisibility(filteredIds);
+ }
+
+ this.#updateStats();
+ }
+
+ /**
+ * Apply filters statically
+ * @param {House[]} houses
+ * @param {Filters} filters
+ * @returns {House[]}
+ */
+ static applyFilters(houses, filters) {
+ return houses.filter((h) => h.matchesFilters(filters));
+ }
+
+ /**
+ * Recalculate scores statically
+ * @param {House[]} houses
+ * @param {Weights} weights
+ */
+ static recalculateScores(houses, weights) {
+ for (const h of houses) {
+ h.scores.current = Math.round(ScoringEngine.calculate(h, weights));
+ }
+ }
+
+ /**
+ * Update stats display
+ */
+ #updateStats() {
+ const count = this.#filtered.length;
+ const avg = count
+ ? Math.round(this.#filtered.reduce((s, h) => s + h.scores.current, 0) / count)
+ : 0;
+ this.#stats.innerHTML = `
+ <strong>${count}</strong> houses shown
+ • Average score: <strong>${avg}</strong>
+ • Use weights sliders to adjust scoring
+ `;
+ }
+}
+
+if (import.meta.url === new URL("./main.js", document.baseURI).href) {
+ new App();
+}