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 = [
Dom.slider("w-price", "Price weight", "price", weights.price, onWeightChange),
Dom.slider(
"w-market",
"Market distance",
"distanceMarket",
weights.distanceMarket,
onWeightChange,
),
Dom.slider(
"w-school",
"School distance",
"distanceSchool",
weights.distanceSchool,
onWeightChange,
),
Dom.slider("w-crime", "Crime rate", "crimeRate", weights.crimeRate, onWeightChange),
Dom.slider("w-safety", "Safety index", "safety", weights.safety, onWeightChange),
Dom.slider("w-students", "S2 students", "s2Students", weights.s2Students, onWeightChange),
Dom.slider(
"w-railway",
"Railway distance",
"distanceRailway",
weights.distanceRailway,
onWeightChange,
),
Dom.slider(
"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;
}
/**
* 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.setHouses(houses, this.#colorParameter);
this.#map.setTrainData(trainStations, trainTracks);
this.#map.setDistricts(districts);
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 = `
${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();
}