From a92ad2b817b2c28b26e869897e03c14d30d0f991 Mon Sep 17 00:00:00 2001 From: Petri Hienonen Date: Fri, 14 Nov 2025 13:23:58 +0200 Subject: Add histogram --- app/components.js | 521 +++++++++++++++++++++++++++++++++++++++++++++++++++--- app/dom.js | 4 +- app/main.js | 4 + app/svg.js | 79 +++++++++ 4 files changed, 585 insertions(+), 23 deletions(-) (limited to 'app') diff --git a/app/components.js b/app/components.js index c952b08..3308b62 100644 --- a/app/components.js +++ b/app/components.js @@ -1,5 +1,338 @@ import { Dom, DomOptions, ToastType } from "dom"; import { AreaParam, Filters, House, HouseParameter, Weights } from "models"; +import { Svg, SvgOptions } from "svg"; + +export class Histogram { + /** @type {HTMLElement} */ + #rootElement; + /** @type {number[]} */ + #values = []; + /** @type {number} */ + #bins = 5; + /** @type {(min: number, max: number) => void} */ + #onBarClick; + /** @type {string} */ + #currentParameter = HouseParameter.price; + + /** + * @param {number[]} values + * @param {number} bins + * @param {(min: number, max: number) => void} onBarClick + * @param {string} parameter + */ + constructor(values, bins, onBarClick, parameter) { + this.#values = values.filter((v) => !Number.isNaN(v) && Number.isFinite(v)); + this.#bins = bins; + this.#onBarClick = onBarClick; + this.#currentParameter = parameter; + this.#rootElement = this.#render(); + } + + /** + * Update histogram with new data + * @param {number[]} values + * @param {string} parameter + */ + update(values, parameter) { + this.#values = values.filter((v) => !Number.isNaN(v) && Number.isFinite(v)); + this.#currentParameter = parameter; + const newElement = this.#render(); + this.#rootElement.replaceWith(newElement); + this.#rootElement = newElement; + } + + /** + * Calculate percentile bins + * @returns {{min: number, max: number, count: number, values: number[]}[]} + */ + #calculateBins() { + if (this.#values.length === 0) return []; + + const sorted = [...this.#values].sort((a, b) => a - b); + const binSize = Math.ceil(sorted.length / this.#bins); + const bins = []; + + for (let i = 0; i < this.#bins; i++) { + const startIdx = i * binSize; + const endIdx = Math.min((i + 1) * binSize - 1, sorted.length - 1); + + if (startIdx >= sorted.length) break; + + const min = sorted[startIdx]; + const max = sorted[endIdx]; + const binValues = sorted.slice(startIdx, endIdx + 1); + + bins.push({ + count: binValues.length, + max, + min, + values: binValues, + }); + } + + return bins; + } + + /** + * Format value based on parameter type + * @param {number} value + * @returns {string} + */ + #formatValue(value) { + switch (this.#currentParameter) { + case HouseParameter.price: + return `${(value / 1000).toFixed(0)}k€`; + case HouseParameter.area: + return `${value.toFixed(0)} m²`; + case HouseParameter.year: + return value.toFixed(0); + case HouseParameter.score: + return value.toFixed(1); + default: + return value.toFixed(0); + } + } + + /** + * Get parameter display name + * @returns {string} + */ + #getParameterName() { + switch (this.#currentParameter) { + case HouseParameter.price: + return "Price"; + case HouseParameter.area: + return "Living Area"; + case HouseParameter.year: + return "Construction Year"; + case HouseParameter.score: + return "Score"; + default: + return "Value"; + } + } + + /** + * Render histogram SVG using existing Svg methods + * @returns {HTMLElement} + */ + #render() { + const bins = this.#calculateBins(); + if (bins.length === 0) { + return Dom.div( + new DomOptions({ + children: [Dom.span("No data available")], + styles: { + color: "#666", + fontSize: "0.8rem", + padding: "1rem", + textAlign: "center", + }, + }), + ); + } + + const maxCount = Math.max(...bins.map((bin) => bin.count)); + const svgWidth = 280; + const svgHeight = 120; + const padding = { bottom: 30, left: 40, right: 10, top: 20 }; + const chartWidth = svgWidth - padding.left - padding.right; + const chartHeight = svgHeight - padding.top - padding.bottom; + const barWidth = chartWidth / bins.length; + + const children = []; + + // Create title + children.push( + Svg.textXY( + svgWidth / 2, + 12, + `${this.#getParameterName()} Distribution`, + new SvgOptions({ + attributes: { + "font-size": "10", + "font-weight": "bold", + "text-anchor": "middle", + }, + styles: { + fill: "#333", + }, + }), + ), + ); + + // Create bars + bins.forEach((bin, index) => { + const barHeight = (bin.count / maxCount) * chartHeight; + const x = padding.left + index * barWidth; + const y = padding.top + chartHeight - barHeight; + + // Create bar rectangle + const bar = Svg.rectXY( + x, + y, + barWidth - 2, + barHeight, + new SvgOptions({ + attributes: { + fill: "#4caf50", + stroke: "#388e3c", + "stroke-width": "0.5", + }, + children: [ + Svg.title( + `Range: ${this.#formatValue(bin.min)} - ${this.#formatValue(bin.max)}\nCount: ${bin.count} properties\nClick to filter this range`, + ), + ], + styles: { + cursor: "pointer", + transition: "fill 0.2s ease", + }, + }), + ); + + // Add event listeners + bar.addEventListener("mouseenter", () => { + bar.setAttribute("fill", "#2e7d32"); + }); + bar.addEventListener("mouseleave", () => { + bar.setAttribute("fill", "#4caf50"); + }); + bar.addEventListener("click", () => { + this.#onBarClick(bin.min, bin.max); + }); + + children.push(bar); + + // Create X-axis label + const shortMin = this.#formatValue(bin.min).replace("k€", "k"); + const shortMax = this.#formatValue(bin.max).replace("k€", "k"); + const labelText = `${shortMin}-${shortMax}`; + + const labelX = x + barWidth / 2; + let labelY = svgHeight - 8; + let transform = ""; + + if (labelText.length > 8) { + transform = `rotate(45 ${labelX} ${labelY})`; + labelY = svgHeight - 12; + } + + children.push( + Svg.textXY( + labelX, + labelY, + labelText, + new SvgOptions({ + attributes: { + "font-size": "8", + "text-anchor": "middle", + transform: transform, + }, + styles: { + fill: "#666", + }, + }), + ), + ); + }); + + // Create Y-axis line + children.push( + Svg.line( + padding.left, + padding.top, + padding.left, + padding.top + chartHeight, + new SvgOptions({ + attributes: { + stroke: "#ccc", + "stroke-width": "1", + }, + }), + ), + ); + + // Create Y-axis labels and ticks + const yStep = maxCount / 3; + for (let i = 0; i <= 3; i++) { + const count = Math.round(i * yStep); + const y = padding.top + chartHeight - (i * chartHeight) / 3; + + // Y-axis label + children.push( + Svg.textXY( + padding.left - 5, + y, + count.toString(), + new SvgOptions({ + attributes: { + dy: "0.3em", + "font-size": "8", + "text-anchor": "end", + }, + styles: { + fill: "#666", + }, + }), + ), + ); + + // Y-axis tick + children.push( + Svg.line( + padding.left - 3, + y, + padding.left, + y, + new SvgOptions({ + attributes: { + stroke: "#ccc", + "stroke-width": "1", + }, + }), + ), + ); + } + + // Create main SVG element + const svgElement = Svg.svg( + new SvgOptions({ + attributes: { + height: svgHeight.toString(), + width: svgWidth.toString(), + }, + children: children, + styles: { + background: "#fafafa", + border: "1px solid #eee", + borderRadius: "4px", + }, + }), + ); + + return Dom.div( + new DomOptions({ + children: [svgElement], + styles: { + background: "white", + border: "1px solid #e0e0e0", + borderRadius: "4px", + margin: "1rem 0", + padding: "0.5rem", + }, + }), + ); + } + + /** + * Get the root DOM element + * @returns {HTMLElement} + */ + render() { + return this.#rootElement; + } +} export class Widgets { /** @@ -111,10 +444,11 @@ export class Widgets { * Create a number filter input * @param {string} id * @param {string} labelText + * @param {string|number} value * @param {(value: number | null) => void} onChange * @returns {HTMLElement} */ - static numberFilter(id, labelText, onChange) { + static numberFilter(id, labelText, value, onChange) { return Dom.div( new DomOptions({ children: [ @@ -132,8 +466,8 @@ export class Widgets { const raw = target.value.trim(); onChange(raw === "" ? null : Number(raw)); }, + value, "any", - "", new DomOptions({ id, styles: { @@ -152,6 +486,10 @@ export class Widgets { } export class Sidebar { + /** @type {Histogram} */ + #histogram; + /** @type {House[]|null} */ + #allHouses = []; /** @type {HTMLElement} */ #rootElement; /** @type {boolean} */ @@ -169,6 +507,44 @@ export class Sidebar { /** @type {(param: string) => void} */ #onAreaColorChange; + /** + * @param {House[]|null} allHouses + * @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( + allHouses, + filters, + weights, + onFilterChange, + onWeightChange, + onColorChange, + onAreaColorChange, + ) { + this.#allHouses = allHouses; + this.#filters = filters; + this.#weights = weights; + this.#onFilterChange = onFilterChange; + this.#onWeightChange = onWeightChange; + this.#onColorChange = onColorChange; + this.#onAreaColorChange = onAreaColorChange; + + const initialParam = HouseParameter.price; + const initialValues = this.#allHouses?.map((house) => house.get(initialParam)); + this.#histogram = new Histogram( + initialValues || [], + 5, + (min, max) => this.#handleHistogramClick(min, max), + initialParam, + ); + + this.#rootElement = this.#render(); + } + /** * @param {Weights} weights * @param {(key: string, value: number) => void} onChange @@ -401,11 +777,11 @@ export class Sidebar { Dom.div( new DomOptions({ children: [ - Widgets.numberFilter("min-price", "Min price (€)", (v) => { + Widgets.numberFilter("min-price", "Min price (€)", filters.minPrice, (v) => { filters.minPrice = v ?? 0; onChange(); }), - Widgets.numberFilter("max-price", "Max price (€)", (v) => { + Widgets.numberFilter("max-price", "Max price (€)", filters.maxPrice, (v) => { filters.maxPrice = v ?? Number.POSITIVE_INFINITY; onChange(); }), @@ -417,11 +793,11 @@ export class Sidebar { }, }), ), - Widgets.numberFilter("min-year", "Min year", (v) => { + Widgets.numberFilter("min-year", "Min year", filters.minYear, (v) => { filters.minYear = v ?? 0; onChange(); }), - Widgets.numberFilter("min-area", "Min area (m²)", (v) => { + Widgets.numberFilter("min-area", "Min area (m²)", filters.minArea, (v) => { filters.minArea = v ?? 0; onChange(); }), @@ -476,21 +852,124 @@ export class Sidebar { } /** - * @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 + * Update histogram data + * @param {House[]} houses + * @param {string} parameter */ - 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(); + updateHistogram(houses, parameter) { + const values = houses.map((house) => house.get(parameter)); + if (this.#histogram) { + this.#histogram.update(values, parameter); + } + } + + /** + * Handle histogram bar click + * @param {number} min + * @param {number} max + */ + /** + * Handle histogram bar click + * @param {number} min + * @param {number} max + */ + #handleHistogramClick(min, max) { + const param = document.getElementById("color-parameter")?.value || HouseParameter.price; + + switch (param) { + case HouseParameter.price: { + this.#filters.minPrice = min; + this.#filters.maxPrice = max; + // Update the input fields to reflect the new filter values + const minPriceInput = document.getElementById("min-price"); + const maxPriceInput = document.getElementById("max-price"); + if (minPriceInput) minPriceInput.value = Math.round(min).toString(); + if (maxPriceInput) maxPriceInput.value = Math.round(max).toString(); + break; + } + case HouseParameter.area: { + this.#filters.minArea = min; + // Update the input field to reflect the new filter value + const minAreaInput = document.getElementById("min-area"); + if (minAreaInput) minAreaInput.value = Math.round(min).toString(); + break; + } + case HouseParameter.year: { + this.#filters.minYear = min; + // Update the input field to reflect the new filter value + const minYearInput = document.getElementById("min-year"); + if (minYearInput) minYearInput.value = Math.round(min).toString(); + break; + } + case HouseParameter.score: { + // Note: You might want to add score filters to your Filters class + console.log(`Score range: ${min} - ${max}`); + // If you add score filters to Filters class, you would do: + // this.#filters.minScore = min; + // this.#filters.maxScore = max; + break; + } + } + // Update the input fields to show the new filter values + this.#updateFilterInputs(); + + // Trigger the filter change to update the application + this.#onFilterChange(); + } + /** + * Update filter input values to match current filters + */ + #updateFilterInputs() { + const minPriceInput = document.getElementById("min-price"); + const maxPriceInput = document.getElementById("max-price"); + const minAreaInput = document.getElementById("min-area"); + const minYearInput = document.getElementById("min-year"); + + if (minPriceInput) + minPriceInput.value = this.#filters.minPrice + ? Math.round(this.#filters.minPrice).toString() + : ""; + if (maxPriceInput) + maxPriceInput.value = + this.#filters.maxPrice !== Number.POSITIVE_INFINITY + ? Math.round(this.#filters.maxPrice).toString() + : ""; + if (minAreaInput) + minAreaInput.value = this.#filters.minArea + ? Math.round(this.#filters.minArea).toString() + : ""; + if (minYearInput) + minYearInput.value = this.#filters.minYear + ? Math.round(this.#filters.minYear).toString() + : ""; + } + + /** + * @param {Histogram} histogram + */ + static histogramSection(histogram) { + return Dom.div( + new DomOptions({ + children: [ + Dom.heading( + 3, + "Distribution", + new DomOptions({ + styles: { + color: "#333", + fontSize: "1.1rem", + margin: "0 0 0.5rem 0", + }, + }), + ), + histogram.render(), + ], + styles: { + borderBottom: "1px solid #eee", + paddingBottom: "1rem", + }, + }), + ); } /** @@ -521,10 +1000,10 @@ export class Sidebar { }, }), ), - // Sidebar content Dom.div( new DomOptions({ children: [ + Sidebar.histogramSection(this.#histogram), Sidebar.dataSection(this.#onColorChange, this.#onAreaColorChange), Sidebar.filtersSection(this.#filters, this.#onFilterChange), Sidebar.weightSection(this.#weights, this.#onWeightChange), diff --git a/app/dom.js b/app/dom.js index 11d2a0a..3ee393c 100644 --- a/app/dom.js +++ b/app/dom.js @@ -88,7 +88,7 @@ export class Dom { * Create an `` * @param { string} type * @param { (e: Event) => void } onChange - * @param { string} value + * @param { string|number} value * @param { string} placeholder * @param {DomOptions} [o] * @returns {HTMLInputElement} @@ -102,7 +102,7 @@ export class Dom { input.type = type; input.placeholder = placeholder; - input.value = value; + input.value = value.toString(); if (onChange) { input.addEventListener("change", onChange); } diff --git a/app/main.js b/app/main.js index da52152..7adb227 100644 --- a/app/main.js +++ b/app/main.js @@ -47,6 +47,7 @@ export class App { // Create sidebar instance this.#sidebar = new Sidebar( + null, this.#filters, this.#weights, () => { @@ -186,6 +187,7 @@ export class App { this.#sidebar.updateDistricts(this.collection.houses); this.#sidebar.setAreaColorParameter(this.#areaParameter); + this.#sidebar.updateHistogram(this.#filtered, this.#houseParameter); const stats = App.#getStats(this.#filtered); this.#stats.replaceWith(stats); @@ -228,6 +230,8 @@ export class App { const stats = App.#getStats(this.#filtered); this.#stats.replaceWith(stats); this.#stats = stats; + + this.#sidebar.updateHistogram(this.#filtered, this.#houseParameter); } /** diff --git a/app/svg.js b/app/svg.js index 92bd825..d977ead 100644 --- a/app/svg.js +++ b/app/svg.js @@ -25,6 +25,85 @@ export class SvgOptions { } export class Svg { + /** + * Create a rectangle with explicit coordinates + * @param {number} x + * @param {number} y + * @param {number} width + * @param {number} height + * @param {SvgOptions} [options=new SvgOptions()] + * @returns {SVGRectElement} + */ + static rectXY(x, y, width, height, options = new SvgOptions()) { + const element = document.createElementNS("http://www.w3.org/2000/svg", "rect"); + for (const [key, value] of Object.entries({ + height: String(height), + width: String(width), + x: String(x), + y: String(y), + ...options.attributes, + })) { + element.setAttribute(key, value); + } + Object.assign(element.style, options.styles); + if (options.id) element.id = options.id; + if (options.classes.length) element.classList.add(...options.classes.filter(Boolean)); + element.append(...options.children.filter(Boolean)); + return element; + } + + /** + * Create a text element at specific coordinates + * @param {number} x + * @param {number} y + * @param {string} text + * @param {SvgOptions} [options=new SvgOptions()] + * @returns {SVGTextElement} + */ + static textXY(x, y, text, options = new SvgOptions()) { + const element = document.createElementNS("http://www.w3.org/2000/svg", "text"); + element.textContent = text; + for (const [key, value] of Object.entries({ + x: String(x), + y: String(y), + ...options.attributes, + })) { + element.setAttribute(key, value); + } + Object.assign(element.style, options.styles); + if (options.id) element.id = options.id; + if (options.classes.length) element.classList.add(...options.classes.filter(Boolean)); + element.append(...options.children.filter(Boolean)); + return element; + } + + /** + * Create a line element + * @param {number} x1 + * @param {number} y1 + * @param {number} x2 + * @param {number} y2 + * @param {SvgOptions} [options=new SvgOptions()] + * @returns {SVGLineElement} + */ + static line(x1, y1, x2, y2, options = new SvgOptions()) { + const element = document.createElementNS("http://www.w3.org/2000/svg", "line"); + for (const [key, value] of Object.entries({ + x1: String(x1), + x2: String(x2), + y1: String(y1), + y2: String(y2), + ...options.attributes, + })) { + element.setAttribute(key, value); + } + Object.assign(element.style, options.styles); + if (options.id) element.id = options.id; + if (options.classes.length) element.classList.add(...options.classes.filter(Boolean)); + element.append(...options.children.filter(Boolean)); + return element; + } + /** * Create an SVG element * @param {SvgOptions} [options=new SvgOptions()] -- cgit v1.2.3-70-g09d2