aboutsummaryrefslogtreecommitdiffstats
path: root/app/components.js
diff options
context:
space:
mode:
authorPetri Hienonen <petri.hienonen@gmail.com>2025-11-09 22:59:02 +0200
committerPetri Hienonen <petri.hienonen@gmail.com>2025-11-11 15:35:03 +0200
commit909773f9d253c61183cc1f9f6193656957946be5 (patch)
tree136075e1946accedda0530dd25940b8931408c5a /app/components.js
parentbe7ec90b500ac68e053f2b58feb085247ef95817 (diff)
downloadhousing-909773f9d253c61183cc1f9f6193656957946be5.tar.zst
Add statistical areas
Diffstat (limited to 'app/components.js')
-rw-r--r--app/components.js864
1 files changed, 864 insertions, 0 deletions
diff --git a/app/components.js b/app/components.js
new file mode 100644
index 0000000..bb11138
--- /dev/null
+++ b/app/components.js
@@ -0,0 +1,864 @@
+import { Dom, DomOptions, ToastType } from "dom";
+import { AreaColorParameter, ColorParameter } from "map";
+import { District, Filters, House, Weights } from "models";
+
+export class Widgets {
+ /**
+ * Show toast notification
+ * @param {string} message
+ * @param {ToastType} [type=ToastType.error]
+ */
+ static toast(message, type = ToastType.error) {
+ return Dom.div(
+ new DomOptions({
+ children: [Dom.p(message)],
+ classes: ["toast", `toast-${type}`],
+ id: "app-toast",
+ styles: {
+ background:
+ type === ToastType.error
+ ? "#f44336"
+ : type === ToastType.warning
+ ? "#ff9800"
+ : "#4caf50",
+ borderRadius: "4px",
+ boxShadow: "0 4px 12px rgba(0,0,0,0.15)",
+ color: "white",
+ fontSize: "14px",
+ fontWeight: "500",
+ maxWidth: "300px",
+ padding: "12px 20px",
+ position: "fixed",
+ right: "20px",
+ top: "20px",
+ transition: "all .3s ease",
+ zIndex: "10000",
+ },
+ }),
+ );
+ }
+
+ /**
+ * Remove all children
+ * @param {HTMLElement} el
+ */
+ static clear(el) {
+ while (el.firstChild) el.removeChild(el.firstChild);
+ }
+
+ /**
+ * 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 slider(id, labelText, weightKey, initialValue, onChange) {
+ const output = Dom.span(
+ initialValue.toFixed(1),
+ new DomOptions({
+ id: `${id}-value`,
+ styles: { color: "#0066cc", fontSize: "0.85rem", fontWeight: "bold", textAlign: "center" },
+ }),
+ );
+
+ return Dom.div(
+ new DomOptions({
+ children: [
+ Dom.label(
+ id,
+ labelText,
+ new DomOptions({
+ children: [
+ Dom.span(
+ labelText,
+ new DomOptions({
+ styles: { fontSize: "0.85rem" },
+ }),
+ ),
+ Dom.span(" "),
+ output,
+ ],
+ }),
+ ),
+ Dom.input(
+ "range",
+ (e) => {
+ const target = /** @type {HTMLInputElement} */ (e.target);
+ const val = Number(target.value);
+ output.textContent = val.toFixed(1);
+ onChange(weightKey, val);
+ },
+ "",
+ "",
+ new DomOptions({
+ attributes: { max: "1", min: "0", step: "0.1", value: initialValue.toString() },
+ id,
+ styles: {
+ margin: "0.5rem 0",
+ width: "100%",
+ },
+ }),
+ ),
+ ],
+ styles: { display: "flex", flexDirection: "column", marginBottom: "1rem" },
+ }),
+ );
+ }
+
+ /**
+ * Create a number filter input
+ * @param {string} id
+ * @param {string} labelText
+ * @param {(value: number | null) => void} onChange
+ * @returns {HTMLElement}
+ */
+ static numberFilter(id, labelText, onChange) {
+ return Dom.div(
+ new DomOptions({
+ children: [
+ Dom.label(
+ id,
+ labelText,
+ new DomOptions({
+ styles: { fontSize: "0.85rem", fontWeight: "bold", marginBottom: "0.25rem" },
+ }),
+ ),
+ 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" },
+ }),
+ );
+ }
+}
+
+export class Sidebar {
+ /** @type {HTMLElement} */
+ #rootElement;
+ /** @type {boolean} */
+ #collapsed = false;
+ /** @type {Filters} */
+ #filters;
+ /** @type {Weights} */
+ #weights;
+ /** @type {() => void} */
+ #onFilterChange;
+ /** @type {(key: string, value: number) => void} */
+ #onWeightChange;
+ /** @type {(param: string) => void} */
+ #onColorChange;
+ /** @type {(param: string) => void} */
+ #onAreaColorChange;
+
+ /**
+ * @param {Filters} filters
+ * @param {Weights} weights
+ * @param {() => void} onFilterChange
+ * @param {(key: string, value: number) => void} onWeightChange
+ * @param {(param: string) => void} onColorChange
+ * @param {(param: string) => void} onAreaColorChange
+ */
+ constructor(filters, weights, onFilterChange, onWeightChange, onColorChange, onAreaColorChange) {
+ this.#filters = filters;
+ this.#weights = weights;
+ this.#onFilterChange = onFilterChange;
+ this.#onWeightChange = onWeightChange;
+ this.#onColorChange = onColorChange;
+ this.#onAreaColorChange = onAreaColorChange;
+ this.#rootElement = this.#render();
+ }
+
+ /**
+ * Render sidebar container
+ * @returns {HTMLElement}
+ */
+ #render() {
+ const sidebar = Dom.div(
+ new DomOptions({
+ children: [
+ // Toggle button
+ Dom.button(
+ "☰",
+ () => this.toggle(),
+ new DomOptions({
+ id: "sidebar-toggle",
+ styles: {
+ background: "none",
+ border: "none",
+ color: "#333",
+ cursor: "pointer",
+ fontSize: "1.5rem",
+ padding: "0.5rem",
+ position: "absolute",
+ right: "0.5rem",
+ top: "0.5rem",
+ zIndex: "10",
+ },
+ }),
+ ),
+ // Sidebar content
+ Dom.div(
+ new DomOptions({
+ children: [
+ // Map Colors Section
+ 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);
+ this.#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",
+ width: "100%",
+ },
+ }),
+ ),
+ ],
+ styles: {
+ display: "flex",
+ flexDirection: "column",
+ marginBottom: "1rem",
+ },
+ }),
+ ),
+ Dom.div(
+ new DomOptions({
+ children: [
+ Dom.label(
+ "area-color-parameter",
+ "Color areas by",
+ new DomOptions({
+ styles: {
+ fontSize: "0.85rem",
+ fontWeight: "bold",
+ marginBottom: "0.25rem",
+ },
+ }),
+ ),
+ Dom.select(
+ (e) => {
+ const target = /** @type {HTMLSelectElement} */ (e.target);
+ this.#onAreaColorChange(target.value);
+ },
+ new DomOptions({
+ children: [
+ Dom.option(AreaColorParameter.none, "None"),
+ Dom.option(
+ AreaColorParameter.foreignSpeakers,
+ "Foreign speakers",
+ ),
+ Dom.option(
+ AreaColorParameter.unemploymentRate,
+ "Unemployment rate",
+ ),
+ Dom.option(AreaColorParameter.averageIncome, "Average income"),
+ Dom.option(
+ AreaColorParameter.higherEducation,
+ "Higher education",
+ ),
+ ],
+ id: "area-color-parameter",
+ styles: {
+ border: "1px solid #ddd",
+ borderRadius: "4px",
+ fontSize: "0.9rem",
+ padding: "0.5rem",
+ width: "100%",
+ },
+ }),
+ ),
+ ],
+ styles: { display: "flex", flexDirection: "column" },
+ }),
+ ),
+ ],
+ styles: {
+ borderBottom: "1px solid #eee",
+ paddingBottom: "1rem",
+ },
+ }),
+ ),
+ // Filters Section
+ Dom.div(
+ new DomOptions({
+ children: [
+ Dom.heading(
+ 3,
+ "Filters",
+ new DomOptions({
+ styles: {
+ color: "#333",
+ fontSize: "1.1rem",
+ margin: "1rem 0 1rem 0",
+ },
+ }),
+ ),
+ Dom.div(
+ new DomOptions({
+ children: [
+ Widgets.numberFilter("min-price", "Min price (€)", (v) => {
+ this.#filters.minPrice = v ?? 0;
+ this.#onFilterChange();
+ }),
+ Widgets.numberFilter("max-price", "Max price (€)", (v) => {
+ this.#filters.maxPrice = v ?? Number.POSITIVE_INFINITY;
+ this.#onFilterChange();
+ }),
+ ],
+ id: "price-row",
+ styles: {
+ display: "flex",
+ gap: "0.5rem",
+ },
+ }),
+ ),
+ Widgets.numberFilter("min-year", "Min year", (v) => {
+ this.#filters.minYear = v ?? 0;
+ this.#onFilterChange();
+ }),
+ Widgets.numberFilter("min-area", "Min area (m²)", (v) => {
+ this.#filters.minArea = v ?? 0;
+ this.#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,
+ );
+ this.#filters.districts = selectedOptions;
+ this.#onFilterChange();
+ },
+ new DomOptions({
+ attributes: { multiple: "true" },
+ children: [],
+ id: "district-select",
+ styles: {
+ border: "1px solid #ddd",
+ borderRadius: "4px",
+ minHeight: "120px",
+ padding: "0.5rem",
+ width: "100%",
+ },
+ }),
+ ),
+ ],
+ id: "district-multi-select",
+ styles: { display: "flex", flexDirection: "column", marginTop: "1rem" },
+ }),
+ ),
+ ],
+ styles: {
+ borderBottom: "1px solid #eee",
+ paddingBottom: "1rem",
+ },
+ }),
+ ),
+ // Weights Section
+ Dom.div(
+ new DomOptions({
+ children: [
+ Dom.heading(
+ 3,
+ "Weights",
+ new DomOptions({
+ styles: {
+ color: "#333",
+ fontSize: "1.1rem",
+ margin: "1rem 0 1rem 0",
+ },
+ }),
+ ),
+ Widgets.slider(
+ "w-price",
+ "Price weight",
+ "price",
+ this.#weights.price,
+ this.#onWeightChange,
+ ),
+ Widgets.slider(
+ "w-market",
+ "Market distance",
+ "distanceMarket",
+ this.#weights.distanceMarket,
+ this.#onWeightChange,
+ ),
+ Widgets.slider(
+ "w-school",
+ "School distance",
+ "distanceSchool",
+ this.#weights.distanceSchool,
+ this.#onWeightChange,
+ ),
+ Widgets.slider(
+ "w-crime",
+ "Crime rate",
+ "crimeRate",
+ this.#weights.crimeRate,
+ this.#onWeightChange,
+ ),
+ Widgets.slider(
+ "w-safety",
+ "Safety index",
+ "safety",
+ this.#weights.safety,
+ this.#onWeightChange,
+ ),
+ Widgets.slider(
+ "w-students",
+ "S2 students",
+ "s2Students",
+ this.#weights.s2Students,
+ this.#onWeightChange,
+ ),
+ Widgets.slider(
+ "w-railway",
+ "Railway distance",
+ "distanceRailway",
+ this.#weights.distanceRailway,
+ this.#onWeightChange,
+ ),
+ Widgets.slider(
+ "w-year",
+ "Construction year",
+ "constructionYear",
+ this.#weights.constructionYear,
+ this.#onWeightChange,
+ ),
+ ],
+ }),
+ ),
+ ],
+ id: "sidebar-content",
+ }),
+ ),
+ ],
+ id: "sidebar",
+ styles: {
+ background: "#fff",
+ borderRight: "1px solid #ddd",
+ display: "flex",
+ flexDirection: "column",
+ flexShrink: "0",
+ overflowY: "auto",
+ padding: "1rem",
+ position: "relative",
+ transition: "width 0.3s ease",
+ width: "300px",
+ },
+ }),
+ );
+ return sidebar;
+ }
+
+ /**
+ * Get the root DOM element
+ * @returns {HTMLElement}
+ */
+ render() {
+ return this.#rootElement;
+ }
+
+ /** Show the sidebar */
+ show() {
+ if (this.#collapsed) {
+ this.toggle();
+ }
+ }
+
+ /** Hide the sidebar */
+ hide() {
+ if (!this.#collapsed) {
+ this.toggle();
+ }
+ }
+
+ /** Toggle sidebar visibility */
+ toggle() {
+ this.#collapsed = !this.#collapsed;
+ const sidebarContent = this.#rootElement.querySelector("#sidebar-content");
+ const toggleButton = this.#rootElement.querySelector("#sidebar-toggle");
+
+ if (this.#collapsed) {
+ this.#rootElement.style.width = "0";
+ this.#rootElement.style.padding = "0";
+ if (sidebarContent) sidebarContent.style.display = "none";
+ if (toggleButton) {
+ toggleButton.textContent = "☰";
+ toggleButton.style.right = "0.5rem";
+ }
+ } else {
+ this.#rootElement.style.width = "300px";
+ this.#rootElement.style.padding = "1rem";
+ if (sidebarContent) sidebarContent.style.display = "block";
+ if (toggleButton) {
+ toggleButton.textContent = "☰";
+ toggleButton.style.right = "0.5rem";
+ }
+ }
+ }
+
+ /**
+ * Update district options in the multi-select
+ * @param {District[]} districts
+ * @param {House[]} houses
+ */
+ updateDistricts(districts, houses) {
+ const districtOptions = this.#renderDistrictOptions(districts, houses);
+ const districtSelect = this.#rootElement.querySelector("#district-select");
+ if (districtSelect) {
+ districtSelect.append(...districtOptions);
+ }
+ }
+
+ /**
+ * Set the area color parameter in the dropdown
+ * @param {string} param
+ */
+ setAreaColorParameter(param) {
+ const areaColorSelect = this.#rootElement.querySelector("#area-color-parameter");
+ if (areaColorSelect) {
+ areaColorSelect.value = param;
+ }
+ }
+
+ /**
+ * Render district options for multi-select
+ * @param {District[]} _districts
+ * @param {House[]} houses
+ * @returns {HTMLOptionElement[]}
+ */
+ #renderDistrictOptions(_districts, houses) {
+ const houseDistricts = [...new Set(houses.map((h) => h.district).filter((d) => d))].sort();
+ return houseDistricts.map((districtName) => Dom.option(districtName, districtName));
+ }
+}
+
+export class Modal {
+ /** @type {HTMLDialogElement} */
+ #dialog;
+ /** @type {AbortController} */
+ #abortController;
+ /** @type {number | undefined} */
+ #timer;
+ /** @type {boolean} */
+ #persistent;
+ /** @type {House} */
+ #house;
+ /** @type {() => void} */
+ #onHide;
+ /** @type {() => void} */
+ #onClearMapTimer;
+
+ /**
+ * Build modal content for a house
+ * @param {House} house
+ * @returns {DocumentFragment}
+ */
+ static content(house) {
+ const frag = document.createDocumentFragment();
+ frag.appendChild(
+ Dom.div(
+ new DomOptions({
+ children: [
+ Dom.heading(
+ 2,
+ house.address,
+ new DomOptions({
+ styles: { color: "#333", fontSize: "20px", margin: "0" },
+ }),
+ ),
+ Dom.span(
+ `Score: ${house.scores.current.toFixed(1)}`,
+ new DomOptions({
+ styles: {
+ background: "#e8f5e9",
+ borderRadius: "4px",
+ color: "#2e7d32",
+ fontSize: "16px",
+ fontWeight: "bold",
+ padding: "4px 8px",
+ },
+ }),
+ ),
+ ],
+ id: "modal-header",
+ styles: {
+ alignItems: "center",
+ display: "flex",
+ justifyContent: "space-between",
+ marginBottom: "20px",
+ },
+ }),
+ ),
+ );
+
+ const grid = Dom.div(
+ new DomOptions({
+ styles: {
+ display: "grid",
+ gap: "15px",
+ gridTemplateColumns: "repeat(2,1fr)",
+ marginBottom: "20px",
+ },
+ }),
+ );
+ const details = [
+ { label: "Price", value: `${house.price} €` },
+ { 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" },
+ { label: "Price", value: `${house.price} €` },
+ { 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);
+ frag.appendChild(
+ Dom.div(
+ new DomOptions({
+ children: [
+ Dom.span(
+ "Description",
+ new DomOptions({
+ styles: { fontSize: "14px", fontWeight: "bold", marginBottom: "5px" },
+ }),
+ ),
+ Dom.p(
+ house.description ?? "No description available.",
+ new DomOptions({
+ styles: { color: "#333", fontSize: "14px", lineHeight: "1.4", marginTop: "5px" },
+ }),
+ ),
+ ],
+ styles: { marginBottom: "20px" },
+ }),
+ ),
+ );
+
+ if (house.images?.length) {
+ const imgSect = Dom.div(
+ new DomOptions({
+ children: [
+ Dom.div(
+ new DomOptions({
+ id: "img_title",
+ styles: { fontSize: "14px", fontWeight: "bold", marginBottom: "10px" },
+ }),
+ ),
+ Dom.div(
+ new DomOptions({
+ children: house.images.slice(0, 3).map((src) => {
+ return Dom.img(
+ src,
+ new DomOptions({
+ attributes: { loading: "lazy" },
+ styles: { borderRadius: "4px", flexShrink: "0", height: "100px" },
+ }),
+ );
+ }),
+
+ styles: { display: "flex", gap: "10px", overflowX: "auto", paddingBottom: "5px" },
+ }),
+ ),
+ ],
+ styles: { marginBottom: "20px" },
+ }),
+ );
+ frag.appendChild(imgSect);
+ }
+
+ return frag;
+ }
+
+ /**
+ * @param {House} house
+ * @param {boolean} persistent
+ * @param {object} positionStyles
+ * @param {() => void} onHide
+ * @param {() => void} onClearMapTimer
+ */
+ constructor(house, persistent, positionStyles, onHide, onClearMapTimer) {
+ this.#house = house;
+ this.#persistent = persistent;
+ this.#onHide = onHide;
+ this.#onClearMapTimer = onClearMapTimer;
+ this.#abortController = new AbortController();
+ this.#dialog = document.createElement("dialog");
+
+ Object.assign(
+ this.#dialog.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",
+ },
+ positionStyles,
+ );
+
+ this.#dialog.append(
+ Dom.button(
+ "x",
+ () => this.hide(),
+ new DomOptions({
+ id: "close-modal-btn",
+ styles: {
+ background: "none",
+ border: "none",
+ color: "#666",
+ cursor: "pointer",
+ fontSize: "24px",
+ position: "absolute",
+ right: "10px",
+ top: "10px",
+ },
+ }),
+ ),
+ Modal.content(house),
+ );
+
+ // Add event listeners with AbortController
+ this.#dialog.addEventListener("close", () => this.hide(), {
+ signal: this.#abortController.signal,
+ });
+ this.#dialog.addEventListener(
+ "mouseenter",
+ () => {
+ clearTimeout(this.#timer);
+ this.#onClearMapTimer();
+ },
+ { signal: this.#abortController.signal },
+ );
+ this.#dialog.addEventListener(
+ "mouseleave",
+ () => {
+ if (!this.#persistent) {
+ this.#timer = window.setTimeout(() => this.hide(), 200);
+ }
+ },
+ { signal: this.#abortController.signal },
+ );
+ }
+
+ render() {
+ return this.#dialog;
+ }
+
+ show() {
+ if (this.#persistent) {
+ this.#dialog.showModal();
+ } else {
+ this.#dialog.show();
+ }
+ }
+
+ hide() {
+ clearTimeout(this.#timer);
+ this.#dialog.close();
+ this.#dialog.remove();
+ this.#abortController.abort();
+ this.#onHide();
+ }
+}