aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--app/dom.js200
-rw-r--r--app/main.js560
-rw-r--r--app/svg.js12
3 files changed, 305 insertions, 467 deletions
diff --git a/app/dom.js b/app/dom.js
index b1a8e8a..cf69e95 100644
--- a/app/dom.js
+++ b/app/dom.js
@@ -34,10 +34,6 @@ export const ToastType = {
warning: "warning",
};
-/**
- * DOM element creation class – every creator applies its own options.
- * @class
- */
export class Dom {
/**
* Create a `<div>`
@@ -216,29 +212,36 @@ export class Dom {
if (o.children) p.append(...o.children);
return p;
}
+}
+export class Widgets {
/**
* Build a modal dialog
+ * @param {object} style
* @param {() => void} onClose
* @returns {HTMLDialogElement}
*/
- static buildModal(onClose) {
+ static buildModal(style, onClose) {
const modal = document.createElement("dialog");
- Object.assign(modal.style, {
- background: "white",
- border: "none",
- borderRadius: "8px",
- boxShadow: "0 4px 20px rgba(0,0,0,0.2)",
- maxHeight: "80vh",
- maxWidth: "600px",
- overflowY: "auto",
- padding: "20px",
- position: "fixed",
- top: "50%",
- transform: "translateY(-50%)",
- width: "90%",
- zIndex: "1000",
- });
+ Object.assign(
+ modal.style,
+ {
+ background: "white",
+ border: "none",
+ borderRadius: "8px",
+ boxShadow: "0 4px 20px rgba(0,0,0,0.2)",
+ maxHeight: "80vh",
+ maxWidth: "600px",
+ overflowY: "auto",
+ padding: "20px",
+ position: "fixed",
+ top: "50%",
+ transform: "translateY(-50%)",
+ width: "90%",
+ zIndex: "1000",
+ },
+ style,
+ );
const closeBtn = Dom.button(
"x",
@@ -264,7 +267,7 @@ export class Dom {
/**
* Build modal content for a house
- * @param {import("./models.js").House} house
+ * @param {House} house
* @returns {DocumentFragment}
*/
static buildModalContent(house) {
@@ -507,130 +510,45 @@ export class Dom {
}
/**
- * Build modal content for a house
- * @param {House} house
- * @returns {DocumentFragment}
+ * Create a number filter input
+ * @param {string} id
+ * @param {string} labelText
+ * @param {(value: number | null) => void} onChange
+ * @returns {HTMLElement}
*/
- static buildHouseModalContent(house) {
- const frag = document.createDocumentFragment();
-
- /* Header */
- const header = Dom.div(
- new DomOptions({
- styles: {
- alignItems: "center",
- display: "flex",
- justifyContent: "space-between",
- marginBottom: "20px",
- },
- }),
- );
- const title = Dom.heading(
- 2,
- house.address,
- new DomOptions({
- styles: { color: "#333", fontSize: "20px", margin: "0" },
- }),
- );
- const score = Dom.span(
- `Score: ${house.scores.current}`,
- new DomOptions({
- styles: {
- background: "#e8f5e9",
- borderRadius: "4px",
- color: "#2e7d32",
- fontSize: "16px",
- fontWeight: "bold",
- padding: "4px 8px",
- },
- }),
- );
- header.append(title, score);
- frag.appendChild(header);
-
- /* Details grid */
- const grid = Dom.div(
+ static addNumberFilter(id, labelText, onChange) {
+ return Dom.div(
new DomOptions({
- 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(
- new DomOptions({
- children: [
- Dom.span(
- label,
- new DomOptions({
- styles: { fontSize: "14px", fontWeight: "bold", marginBottom: "4px" },
- }),
- ),
- Dom.span(value, new DomOptions({ styles: { color: "#333", fontSize: "14px" } })),
- ],
- }),
- );
- grid.appendChild(item);
- }
- frag.appendChild(grid);
-
- /* Description */
- const descSect = Dom.div(new DomOptions({ styles: { marginBottom: "20px" } }));
- const descTitle = Dom.span(
- "Description",
- new DomOptions({
- styles: { fontSize: "14px", fontWeight: "bold", marginBottom: "5px" },
- }),
- );
- const descText = Dom.p(
- house.description || "No description available.",
- new DomOptions({
- styles: { color: "#333", fontSize: "14px", lineHeight: "1.4", marginTop: "5px" },
- }),
- );
- descSect.append(descTitle, descText);
- frag.appendChild(descSect);
-
- /* Images */
- if (house.images?.length) {
- const imgSect = Dom.div(new DomOptions({ styles: { marginBottom: "20px" } }));
- const imgTitle = Dom.span(
- "Images",
- new DomOptions({
- styles: { fontSize: "14px", fontWeight: "bold", marginBottom: "10px" },
- }),
- );
- const imgCont = Dom.div(
- new DomOptions({
- styles: { display: "flex", gap: "10px", overflowX: "auto", paddingBottom: "5px" },
- }),
- );
- for (const src of house.images.slice(0, 3)) {
- imgCont.appendChild(
- Dom.img(
- src,
+ children: [
+ Dom.label(
+ id,
+ labelText,
new DomOptions({
- attributes: { loading: "lazy" },
- styles: { borderRadius: "4px", flexShrink: "0", height: "100px" },
+ styles: { fontSize: "0.85rem", fontWeight: "bold", marginBottom: "0.25rem" },
}),
),
- );
- }
- imgSect.append(imgTitle, imgCont);
- frag.appendChild(imgSect);
- }
-
- return frag;
+ Dom.input(
+ "number",
+ (e) => {
+ const target = /** @type {HTMLInputElement} */ (e.target);
+ const raw = target.value.trim();
+ onChange(raw === "" ? null : Number(raw));
+ },
+ "any",
+ "",
+ new DomOptions({
+ id,
+ styles: {
+ border: "1px solid #ddd",
+ borderRadius: "4px",
+ fontSize: "0.9rem",
+ padding: "0.5rem",
+ },
+ }),
+ ),
+ ],
+ styles: { display: "flex", flexDirection: "column", marginBottom: "1.75rem" },
+ }),
+ );
}
}
diff --git a/app/main.js b/app/main.js
index b19d624..3c9b637 100644
--- a/app/main.js
+++ b/app/main.js
@@ -1,4 +1,4 @@
-import { Dom, DomOptions } from "dom";
+import { Dom, DomOptions, Widgets } from "dom";
import { ColorParameter, MapEl } from "map";
import {
DataProvider,
@@ -54,18 +54,25 @@ export class App {
this.#controls = App.buildControls(
this.#filters,
this.#weights,
- () => this.#applyFilters(),
+ () => {
+ 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.#updateMapHouseColors();
+ App.#recalculateScores(this.#houses, this.#weights);
+ this.#map?.setColorParameter(this.#colorParameter);
this.#updateStats();
},
(param) => {
this.#colorParameter = param;
- this.#updateMapHouseColors();
+ this.#map?.setColorParameter(this.#colorParameter);
},
);
@@ -157,6 +164,69 @@ export class App {
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",
@@ -171,275 +241,158 @@ export class App {
}),
);
- // Color parameter section
- const colorSection = Dom.div(
- new DomOptions({
- styles: {
- borderBottom: "1px solid #eee",
- paddingBottom: "1rem",
- },
- }),
- );
-
- const colorTitle = Dom.heading(
- 3,
- "Map Colors",
- new DomOptions({
- styles: {
- color: "#333",
- fontSize: "1.1rem",
- margin: "0 0 1rem 0",
- },
- }),
- );
-
- const colorGroup = Dom.div(
- new DomOptions({
- styles: { display: "flex", flexDirection: "column" },
- }),
- );
-
- const colorLabel = Dom.label(
- "color-parameter",
- "Color houses by",
- new DomOptions({
- styles: { fontSize: "0.85rem", fontWeight: "bold", marginBottom: "0.25rem" },
- }),
- );
-
- const colorSelect = Dom.select(
- (e) => {
- const target = /** @type {HTMLSelectElement} */ (e.target);
- onColorChange(target.value);
- },
- new DomOptions({
- id: "color-parameter",
- 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(
- new DomOptions({
- styles: {
- borderBottom: "1px solid #eee",
- paddingBottom: "1rem",
- },
- }),
- );
-
- const filterTitle = Dom.heading(
- 3,
- "Filters",
- new DomOptions({
- styles: {
- color: "#333",
- fontSize: "1.1rem",
- margin: "0 0 1rem 0",
- },
- }),
- );
-
- filterSection.appendChild(filterTitle);
-
- // Price filters in a row
- const priceRow = Dom.div(
- new DomOptions({
- 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(
- new DomOptions({
- styles: { display: "flex", flexDirection: "column" },
- }),
- );
-
- const districtLabel = Dom.label(
- "district-select",
- "Districts",
- new DomOptions({
- styles: { fontSize: "0.85rem", fontWeight: "bold", marginBottom: "0.25rem" },
- }),
- );
-
- const districtSelect = 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" },
- id: "district-select",
- 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(
- new DomOptions({
- styles: {
- borderBottom: "1px solid #eee",
- paddingBottom: "1rem",
- },
- }),
- );
-
- const weightsTitle = Dom.heading(
- 3,
- "Weights",
- new DomOptions({
- styles: {
- color: "#333",
- fontSize: "1.1rem",
- margin: "0 0 1rem 0",
- },
- }),
- );
+ controls.append(
+ Dom.div(
+ new DomOptions({
+ children: [
+ Dom.heading(
+ 3,
+ "Filters",
+ new DomOptions({
+ styles: {
+ color: "#333",
+ fontSize: "1.1rem",
+ margin: "0 0 1rem 0",
+ },
+ }),
+ ),
- 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.div(
+ new DomOptions({
+ children: [
+ Widgets.addNumberFilter("min-price", "Min price (€)", (v) => {
+ filters.minPrice = v ?? 0;
+ onFilterChange();
+ }),
+
+ Widgets.addNumberFilter("max-price", "Max price (€)", (v) => {
+ filters.maxPrice = v ?? Number.POSITIVE_INFINITY;
+ onFilterChange();
+ }),
+ ],
+ id: "price-row",
+ styles: {
+ display: "flex",
+ gap: "0.5rem",
+ },
+ }),
+ ),
+ Widgets.addNumberFilter("min-year", "Min year", (v) => {
+ filters.minYear = v ?? 0;
+ onFilterChange();
+ }),
+
+ Widgets.addNumberFilter("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.slider(
- "w-year",
- "Construction year",
- "constructionYear",
- weights.constructionYear,
- onWeightChange,
+ 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",
+ },
+ }),
),
- ];
-
- 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(
- new DomOptions({
- styles: { display: "flex", flexDirection: "column", marginBottom: "0.75rem" },
- }),
- );
-
- const label = Dom.label(
- id,
- labelText,
- new DomOptions({
- styles: { fontSize: "0.85rem", fontWeight: "bold", marginBottom: "0.25rem" },
- }),
);
- const input = Dom.input(
- "number",
- (e) => {
- const target = /** @type {HTMLInputElement} */ (e.target);
- const raw = target.value.trim();
- onChange(raw === "" ? null : Number(raw));
- },
- "any",
- "",
- new DomOptions({
- id,
- styles: {
- border: "1px solid #ddd",
- borderRadius: "4px",
- fontSize: "0.9rem",
- padding: "0.5rem",
- },
- }),
- );
-
- group.append(label, input);
- return group;
+ return controls;
}
/**
@@ -460,16 +413,20 @@ export class App {
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%",
- });
+ this.#modal = Widgets.buildModal(
+ {
+ left: "auto",
+ maxHeight: "80vh",
+ maxWidth: "400px",
+ right: "20px",
+ top: "50%",
+ transform: "translateY(-50%)",
+ width: "90%",
+ },
+
+ () => this.#hideModal(),
+ );
+ Object.assign(this.#modal.style);
// Add hover grace period listeners
this.#modal.addEventListener("mouseenter", () => {
@@ -485,7 +442,7 @@ export class App {
}
});
- this.#modal.appendChild(Dom.buildHouseModalContent(house));
+ this.#modal.appendChild(Widgets.buildModalContent(house));
document.body.appendChild(this.#modal);
if (persistent) {
@@ -495,9 +452,6 @@ export class App {
}
}
- /**
- * Hide the modal
- */
#hideModal() {
this.#modal?.close();
this.#modal?.remove();
@@ -546,7 +500,7 @@ export class App {
}
// Populate district multi-select
- const districtOptions = App.renderDistrictOptions(this.#districts, this.#houses);
+ const districtOptions = App.#renderDistrictOptions(this.#districts, this.#houses);
const districtSelect = this.#controls.querySelector("#district-select");
if (districtSelect) {
districtSelect.append(...districtOptions);
@@ -559,62 +513,28 @@ export class App {
}
/**
- * 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) {
+ 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) {
+ 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
diff --git a/app/svg.js b/app/svg.js
index ad63910..92bd825 100644
--- a/app/svg.js
+++ b/app/svg.js
@@ -2,10 +2,10 @@ import { LineString, Point, Polygon } from "geom";
export class SvgOptions {
attributes;
- styles;
- id;
- classes;
children;
+ classes;
+ id;
+ styles;
/**
* @param {Object} [options]
@@ -17,10 +17,10 @@ export class SvgOptions {
*/
constructor({ attributes = {}, styles = {}, id = "", classes = [], children = [] } = {}) {
this.attributes = attributes;
- this.styles = styles;
- this.id = id;
- this.classes = classes;
this.children = children;
+ this.classes = classes;
+ this.id = id;
+ this.styles = styles;
}
}