// main.js
import { Dom, DomOptions, Modal, Widgets } 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} */
#map;
/** @type {HTMLElement} */
#stats;
/** @type {HTMLElement} */
#controls;
/** @type {Modal|null} */
#modal = 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",
});
this.#controls = App.buildControls(
this.#filters,
this.#weights,
() => {
this.#filtered = this.#houses.filter((h) => h.matchesFilters(this.#filters));
if (this.#map) {
const filteredIds = this.#filtered.map((h) => h.id);
this.#map.updateHouseVisibility(filteredIds);
}
this.#updateStats();
},
(key, value) => {
if (key in this.#weights) {
this.#weights[/** @type {keyof Weights} */ (key)] = value;
}
App.#recalculateScores(this.#houses, this.#weights);
this.#map?.setColorParameter(this.#colorParameter);
this.#updateStats();
},
(param) => {
this.#colorParameter = param;
this.#map?.setColorParameter(this.#colorParameter);
},
);
this.#map = new MapEl({
onHouseClick: (houseId, persistent) => this.#showHouseModal(houseId, persistent),
onHouseHover: (houseId, hide) => {
if (hide) {
this.#modal?.hide();
} else {
this.#showHouseModal(houseId, false);
}
},
});
this.#stats = Dom.div(
new DomOptions({
id: "stats",
styles: {
background: "#fff",
borderTop: "1px solid #ddd",
flexShrink: "0",
fontSize: "0.95rem",
padding: "0.75rem 1rem",
},
}),
);
const loading = Dom.span(
"Loading data…",
new DomOptions({
id: "loading",
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",
},
}),
);
document.body.append(
loading,
Dom.div(
new DomOptions({
children: [
this.#controls,
Dom.div(
new DomOptions({
children: [this.#map.svg, this.#stats],
id: "map-container",
styles: {
display: "flex",
flex: "1",
flexDirection: "column",
minWidth: "0", // Prevents flex overflow
},
}),
),
],
id: "main",
styles: {
display: "flex",
flex: "1",
overflow: "hidden",
},
}),
),
);
this.#initialize(loading);
}
/**
* 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(
new DomOptions({
children: [
Dom.div(
new DomOptions({
children: [
Dom.heading(
3,
"Map Colors",
new DomOptions({
styles: {
color: "#333",
fontSize: "1.1rem",
margin: "0 0 1rem 0",
},
}),
),
Dom.div(
new DomOptions({
children: [
Dom.label(
"color-parameter",
"Color houses by",
new DomOptions({
styles: {
fontSize: "0.85rem",
fontWeight: "bold",
marginBottom: "0.25rem",
},
}),
),
Dom.select(
(e) => {
const target = /** @type {HTMLSelectElement} */ (e.target);
onColorChange(target.value);
},
new DomOptions({
children: [
Dom.option(ColorParameter.price, "Price"),
Dom.option(ColorParameter.score, "Score"),
Dom.option(ColorParameter.year, "Construction Year"),
Dom.option(ColorParameter.area, "Living Area"),
],
id: "color-parameter",
styles: {
border: "1px solid #ddd",
borderRadius: "4px",
fontSize: "0.9rem",
padding: "0.5rem",
},
}),
),
],
styles: { display: "flex", flexDirection: "column" },
}),
),
],
styles: {
borderBottom: "1px solid #eee",
paddingBottom: "1rem",
},
}),
),
],
id: "color-section",
styles: {
background: "#fff",
borderRight: "1px solid #ddd",
display: "flex",
flexDirection: "column",
flexShrink: "0",
gap: "1rem",
overflowY: "auto",
padding: "1rem",
width: "300px",
},
}),
);
controls.append(
Dom.div(
new DomOptions({
children: [
Dom.heading(
3,
"Filters",
new DomOptions({
styles: {
color: "#333",
fontSize: "1.1rem",
margin: "0 0 1rem 0",
},
}),
),
Dom.div(
new DomOptions({
children: [
Widgets.numberFilter("min-price", "Min price (€)", (v) => {
filters.minPrice = v ?? 0;
onFilterChange();
}),
Widgets.numberFilter("max-price", "Max price (€)", (v) => {
filters.maxPrice = v ?? Number.POSITIVE_INFINITY;
onFilterChange();
}),
],
id: "price-row",
styles: {
display: "flex",
gap: "0.5rem",
},
}),
),
Widgets.numberFilter("min-year", "Min year", (v) => {
filters.minYear = v ?? 0;
onFilterChange();
}),
Widgets.numberFilter("min-area", "Min area (m²)", (v) => {
filters.minArea = v ?? 0;
onFilterChange();
}),
Dom.div(
new DomOptions({
children: [
Dom.label(
"district-select",
"Districts",
new DomOptions({
styles: { fontSize: "0.85rem", fontWeight: "bold", marginBottom: "0.25rem" },
}),
),
Dom.select(
(e) => {
const target = /** @type {HTMLSelectElement} */ (e.target);
const selectedOptions = Array.from(target.selectedOptions).map(
(opt) => opt.value,
);
filters.districts = selectedOptions;
onFilterChange();
},
new DomOptions({
attributes: { multiple: "true" },
children: [],
id: "district-select",
styles: {
border: "1px solid #ddd",
borderRadius: "4px",
minHeight: "120px",
padding: "0.5rem",
},
}),
),
],
id: "district-multi-select",
styles: { display: "flex", flexDirection: "column" },
}),
),
],
id: "filter-section",
styles: {
borderBottom: "1px solid #eee",
paddingBottom: "1rem",
},
}),
),
Dom.div(
new DomOptions({
children: [
Dom.heading(
3,
"Weights",
new DomOptions({
styles: {
color: "#333",
fontSize: "1.1rem",
margin: "0 0 1rem 0",
},
}),
),
Widgets.slider("w-price", "Price weight", "price", weights.price, onWeightChange),
Widgets.slider(
"w-market",
"Market distance",
"distanceMarket",
weights.distanceMarket,
onWeightChange,
),
Widgets.slider(
"w-school",
"School distance",
"distanceSchool",
weights.distanceSchool,
onWeightChange,
),
Widgets.slider("w-crime", "Crime rate", "crimeRate", weights.crimeRate, onWeightChange),
Widgets.slider("w-safety", "Safety index", "safety", weights.safety, onWeightChange),
Widgets.slider(
"w-students",
"S2 students",
"s2Students",
weights.s2Students,
onWeightChange,
),
Widgets.slider(
"w-railway",
"Railway distance",
"distanceRailway",
weights.distanceRailway,
onWeightChange,
),
Widgets.slider(
"w-year",
"Construction year",
"constructionYear",
weights.constructionYear,
onWeightChange,
),
],
id: "weights-section",
styles: {
borderBottom: "1px solid #eee",
paddingBottom: "1rem",
},
}),
),
);
return controls;
}
/**
* 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);
}
// Hide existing modal
this.#modal?.hide();
this.#modal = new Modal(
house,
persistent,
{
left: "auto",
maxHeight: "80vh",
maxWidth: "400px",
right: "20px",
top: "50%",
transform: "translateY(-50%)",
width: "90%",
},
() => {
this.#modal = null;
this.#persistent = false;
if (this.#map) {
this.#map.setModalPersistence(false);
this.#map.clearModalTimer();
}
},
() => {
if (this.#map) {
this.#map.clearModalTimer();
}
},
);
document.body.appendChild(this.#modal.render());
this.#modal.show();
}
/**
* Load data and initialize application
* @param {HTMLElement} loading
*/
async #initialize(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();
this.#map.initialize(
districts,
coastLine,
mainRoads,
trainTracks,
trainStations,
houses,
this.#colorParameter,
);
// 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();
}
}
/**
* 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));
}
/**
* 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));
}
}
#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 = `
${count} houses shown
• Average score: ${avg}
• Use weights sliders to adjust scoring
`;
}
}
if (import.meta.url === new URL("./main.js", document.baseURI).href) {
new App();
}