diff options
| -rw-r--r-- | app/components.js | 58 | ||||
| -rw-r--r-- | app/dom.js | 12 | ||||
| -rw-r--r-- | app/geometry.js | 8 | ||||
| -rw-r--r-- | app/main.js | 209 | ||||
| -rw-r--r-- | app/map.js | 456 | ||||
| -rw-r--r-- | app/models.js | 270 | ||||
| -rw-r--r-- | download.js | 20 |
7 files changed, 528 insertions, 505 deletions
diff --git a/app/components.js b/app/components.js index bb11138..6531206 100644 --- a/app/components.js +++ b/app/components.js @@ -1,6 +1,5 @@ import { Dom, DomOptions, ToastType } from "dom"; -import { AreaColorParameter, ColorParameter } from "map"; -import { District, Filters, House, Weights } from "models"; +import { AreaParam, District, Filters, House, HouseParameter, Weights } from "models"; export class Widgets { /** @@ -193,7 +192,7 @@ export class Sidebar { * @returns {HTMLElement} */ #render() { - const sidebar = Dom.div( + return Dom.div( new DomOptions({ children: [ // Toggle button @@ -256,10 +255,10 @@ export class Sidebar { }, 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"), + Dom.option(HouseParameter.price, "Price"), + Dom.option(HouseParameter.score, "Score"), + Dom.option(HouseParameter.year, "Construction Year"), + Dom.option(HouseParameter.area, "Living Area"), ], id: "color-parameter", styles: { @@ -300,20 +299,11 @@ export class Sidebar { }, 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", - ), + Dom.option(AreaParam.none, "None"), + Dom.option(AreaParam.foreignSpeakers, "Foreign speakers"), + Dom.option(AreaParam.unemploymentRate, "Unemployment rate"), + Dom.option(AreaParam.averageIncome, "Average income"), + Dom.option(AreaParam.higherEducation, "Higher education"), ], id: "area-color-parameter", styles: { @@ -520,7 +510,6 @@ export class Sidebar { }, }), ); - return sidebar; } /** @@ -545,7 +534,6 @@ export class Sidebar { } } - /** Toggle sidebar visibility */ toggle() { this.#collapsed = !this.#collapsed; const sidebarContent = this.#rootElement.querySelector("#sidebar-content"); @@ -572,11 +560,10 @@ export class Sidebar { /** * Update district options in the multi-select - * @param {District[]} districts * @param {House[]} houses */ - updateDistricts(districts, houses) { - const districtOptions = this.#renderDistrictOptions(districts, houses); + updateDistricts(houses) { + const districtOptions = this.#renderDistrictOptions(houses); const districtSelect = this.#rootElement.querySelector("#district-select"); if (districtSelect) { districtSelect.append(...districtOptions); @@ -596,11 +583,10 @@ export class Sidebar { /** * Render district options for multi-select - * @param {District[]} _districts * @param {House[]} houses * @returns {HTMLOptionElement[]} */ - #renderDistrictOptions(_districts, houses) { + #renderDistrictOptions(houses) { const houseDistricts = [...new Set(houses.map((h) => h.district).filter((d) => d))].sort(); return houseDistricts.map((districtName) => Dom.option(districtName, districtName)); } @@ -615,8 +601,6 @@ export class Modal { #timer; /** @type {boolean} */ #persistent; - /** @type {House} */ - #house; /** @type {() => void} */ #onHide; /** @type {() => void} */ @@ -682,12 +666,6 @@ export class Modal { { 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( @@ -696,7 +674,12 @@ export class Modal { Dom.span( label, new DomOptions({ - styles: { fontSize: "14px", fontWeight: "bold", marginBottom: "4px" }, + styles: { + fontSize: "14px", + fontWeight: "bold", + marginBottom: "4px", + marginRight: "4px", + }, }), ), Dom.span(value, new DomOptions({ styles: { color: "#333", fontSize: "14px" } })), @@ -771,7 +754,6 @@ export class Modal { * @param {() => void} onClearMapTimer */ constructor(house, persistent, positionStyles, onHide, onClearMapTimer) { - this.#house = house; this.#persistent = persistent; this.#onHide = onHide; this.#onClearMapTimer = onClearMapTimer; @@ -38,7 +38,7 @@ export class Dom { * @param {DomOptions} options * @returns {HTMLDivElement} */ - static div(options) { + static div(options = new DomOptions()) { const div = document.createElement("div"); Object.assign(div.style, options.styles); if (options.id) div.id = options.id; @@ -110,6 +110,16 @@ export class Dom { } /** + * Create a `<strong>` + * @param {string} text + */ + static strong(text) { + const strong = document.createElement("strong"); + strong.textContent = text; + return strong; + } + + /** * Create a `<label>` * @param {string} to * @param {string} text diff --git a/app/geometry.js b/app/geometry.js index ac6b46c..0952bbb 100644 --- a/app/geometry.js +++ b/app/geometry.js @@ -1066,7 +1066,7 @@ export class Feature { } } -export class Collection { +export class FeatureCollection { /** * @param {Feature[]} features - Feature array */ @@ -1088,15 +1088,15 @@ export class Collection { /** * Create from GeoJSON * @param {Object} geojson - GeoJSON collection - * @returns {Collection} + * @returns {FeatureCollection} */ /** * @param {{features: any[]}} geojson - * @returns {Collection} + * @returns {FeatureCollection} */ static fromGeoJSON(geojson) { const features = geojson.features.map(Feature.fromGeoJSON).filter((x) => x !== null); - return new Collection(features); + return new FeatureCollection(features); } /** diff --git a/app/main.js b/app/main.js index 3995a07..f4227d9 100644 --- a/app/main.js +++ b/app/main.js @@ -2,12 +2,14 @@ import { Modal, Sidebar } from "components"; import { Dom, DomOptions } from "dom"; -import { AreaColorParameter, ColorParameter, MapEl } from "map"; +import { MapEl } from "map"; import { - DataProvider, + AreaParam, + Collection, District, Filters, House, + HouseParameter, ScoringEngine, StatisticalArea, TrainStation, @@ -18,20 +20,14 @@ import { export class App { /** @type {House[]} */ #houses = []; - /** @type {TrainTracks[]} */ - #trainTracks = []; - /** @type {TrainStation[]} */ - #trainStations = []; - /** @type {StatisticalArea[]} */ - #statAreas = []; + /** @type {Collection|null} */ + collection = null; /** @type {House[]} */ #filtered = []; /** @type {Filters} */ #filters = new Filters(); /** @type {Weights} */ #weights = new Weights(); - /** @type {District[]} */ - #districts = []; /** @type {MapEl} */ #map; /** @type {HTMLElement} */ @@ -42,10 +38,10 @@ export class App { #modal = null; /** @type {boolean} */ #persistent = false; - /** @type {string} */ - #colorParameter = ColorParameter.price; - /** @type {string} */ - #areaColorParameter = AreaColorParameter.unemploymentRate; + /** @type {HouseParameter} */ + #houseParameter = HouseParameter.price; + /** @type {AreaParam} */ + #areaParameter = AreaParam.unemploymentRate; constructor() { // Set up main layout container @@ -61,10 +57,33 @@ export class App { this.#sidebar = new Sidebar( this.#filters, this.#weights, - () => this.#onFilterChange(), - (key, value) => this.#onWeightChange(key, value), - (param) => this.#onColorChange(param), - (param) => this.#onAreaColorChange(param), + () => { + this.#filtered = this.#houses.filter((h) => h.matchesFilters(this.#filters)); + const filteredIds = this.#filtered.map((h) => h.id); + this.#map.updateHouseVisibility(filteredIds); + + const stats = App.#getStats(this.#filtered); + this.#stats.replaceWith(stats); + this.#stats = stats; + }, + (key, value) => { + if (key in this.#weights) { + this.#weights[/** @type {keyof Weights} */ (key)] = value; + } + App.#recalculateScores(this.#houses, this.#weights); + this.#map.updateHousesColor(this.#houseParameter); + const stats = App.#getStats(this.#filtered); + this.#stats.replaceWith(stats); + this.#stats = stats; + }, + (param) => { + this.#houseParameter = param; + this.#map.updateHousesColor(this.#houseParameter); + }, + (param) => { + this.#areaParameter = param; + this.#map.updateArea(this.#areaParameter); + }, ); this.#map = new MapEl({ @@ -78,19 +97,6 @@ export class App { }, }); - 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({ @@ -112,6 +118,7 @@ export class App { }), ); + this.#stats = App.#getStats(this.#filtered); document.body.append( loading, Dom.div( @@ -144,50 +151,6 @@ export class App { } /** - * Handle filter changes - */ - #onFilterChange() { - 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(); - } - - /** - * Handle weight changes - * @param {string} key - * @param {number} value - */ - #onWeightChange(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(); - } - - /** - * Handle color parameter changes - * @param {string} param - */ - #onColorChange(param) { - this.#colorParameter = param; - this.#map?.setColorParameter(this.#colorParameter); - } - - /** - * Handle area color parameter changes - * @param {string} param - */ - #onAreaColorChange(param) { - this.#areaColorParameter = param; - this.#map?.setAreaColorParameter(this.#areaColorParameter); - } - - /** * Show modal with house details * @param {string} houseId * @param {boolean} persistent @@ -197,9 +160,7 @@ export class App { if (!house) return; this.#persistent = persistent; - if (this.#map) { - this.#map.setModalPersistence(persistent); - } + this.#map.setModalPersistence(persistent); // Hide existing modal this.#modal?.hide(); @@ -219,15 +180,11 @@ export class App { () => { this.#modal = null; this.#persistent = false; - if (this.#map) { - this.#map.setModalPersistence(false); - this.#map.clearModalTimer(); - } + this.#map.setModalPersistence(false); + this.#map.clearModalTimer(); }, () => { - if (this.#map) { - this.#map.clearModalTimer(); - } + this.#map.clearModalTimer(); }, ); @@ -241,43 +198,16 @@ export class App { */ async #initialize(loading) { try { - const [districts, houses, trainStations, trainTracks, coastLine, mainRoads, statAreas] = - await Promise.all([ - DataProvider.getDistricts(), - DataProvider.getHouses(), - DataProvider.getTrainStations(), - DataProvider.getTrainTracks(), - DataProvider.getCoastline(), - DataProvider.getMainRoads(), - DataProvider.getStatisticalAreas(), - ]); - this.#districts = districts; - this.#houses = houses; - this.#trainStations = trainStations; - this.#trainTracks = trainTracks; - this.#statAreas = statAreas; - - this.#filtered = houses.slice(); + this.collection = await Collection.get(); + this.#filtered = this.collection.houses.slice(); + this.#map.initialize(this.collection, this.#houseParameter, this.#areaParameter); - this.#map.initialize( - districts, - coastLine, - mainRoads, - trainTracks, - trainStations, - houses, - statAreas, - this.#colorParameter, - ); + this.#sidebar.updateDistricts(this.#houses); + this.#sidebar.setAreaColorParameter(this.#areaParameter); - // Set default area coloring to unemployment rate - this.#map.setAreaColorParameter(this.#areaColorParameter); - - // Update sidebar with districts and area color parameter - this.#sidebar.updateDistricts(this.#districts, this.#houses); - this.#sidebar.setAreaColorParameter(this.#areaColorParameter); - - this.#updateStats(); + const stats = App.#getStats(this.#filtered); + this.#stats.replaceWith(stats); + this.#stats = stats; } finally { loading.remove(); } @@ -296,30 +226,37 @@ export class App { /** * Update statistics display using DOM methods + * @param {House[]} filtered */ - #updateStats() { - const count = this.#filtered.length; - const avg = count - ? Math.round(this.#filtered.reduce((s, h) => s + h.scores.current, 0) / count) - : 0; - - // Clear existing content - this.#stats.innerHTML = ""; - - // Create elements using DOM methods - const countStrong = document.createElement("strong"); - countStrong.textContent = count.toString(); - - const avgStrong = document.createElement("strong"); - avgStrong.textContent = avg.toString(); + static #getStats(filtered) { + const stats = Dom.div( + new DomOptions({ + id: "stats", + styles: { + background: "#fff", + borderTop: "1px solid #ddd", + flexShrink: "0", + fontSize: "0.95rem", + padding: "0.75rem 1rem", + }, + }), + ); + const countStrong = Dom.strong(filtered.length.toString()); + const avgStrong = Dom.strong( + (filtered.length + ? Math.round(filtered.reduce((s, h) => s + h.scores.current, 0) / filtered.length) + : 0 + ).toString(), + ); // Append all elements - this.#stats.append( + stats.append( countStrong, document.createTextNode(" houses shown • Average score: "), avgStrong, document.createTextNode(" • Use weights sliders to adjust scoring"), ); + return stats; } } @@ -1,31 +1,17 @@ -import { Bounds, Collection, Feature, LineString, MultiLineString, Point } from "geom"; -import { District, House, StatisticalArea, TrainStation, TrainTracks } from "models"; +import { Bounds, Feature, FeatureCollection, LineString, MultiLineString, Point } from "geom"; +import { + AreaParam, + Collection, + District, + House, + HouseParameter, + StatisticalArea, + TrainStation, + TrainTracks, +} from "models"; import { Svg, SvgOptions } from "svg"; /** - * Color parameters for house markers - * @enum {string} - */ -export const ColorParameter = { - area: "livingArea", - price: "price", - score: "score", - year: "constructionYear", -}; - -/** - * Area color parameters for statistical areas - * @enum {string} - */ -export const AreaColorParameter = { - averageIncome: "averageIncome", - foreignSpeakers: "foreignSpeakers", - higherEducation: "higherEducation", - none: "none", - unemploymentRate: "unemploymentRate", -}; - -/** * Math utility functions */ export class MapMath { @@ -76,10 +62,8 @@ export class PanningConfig { export class MapEl { /** @type {SVGSVGElement} */ svg; - /** @type {House[]} */ - #houses = []; - /** @type {StatisticalArea[]} */ - #statAreas = []; + /** @type {Collection|null} */ + #collection = null; /** @type {SVGGElement|null} */ #housesGroup = null; /** @type {SVGGElement|null} */ @@ -99,9 +83,6 @@ export class MapEl { /** @type {number} */ #viewHeightMeters = 10000; // Initial view height in meters /** @type {string} */ - #areaColorParameter = AreaColorParameter.none; - /** @type {Object} */ - #statAreaRanges = {}; /** * @param {Object} options @@ -212,45 +193,15 @@ export class MapEl { } /** - * Calculate min/max ranges for statistical area values - * @param {StatisticalArea[]} statAreas - */ - #calculateStatAreaRanges(statAreas) { - this.#statAreaRanges = {}; - - // Calculate ranges for each parameter type - const parameters = [ - AreaColorParameter.foreignSpeakers, - AreaColorParameter.unemploymentRate, - AreaColorParameter.averageIncome, - AreaColorParameter.higherEducation, - ]; - - for (const param of parameters) { - const values = statAreas.map((area) => MapEl.#getStatisticalAreaValue(area, param)); - const min = Math.min(...values); - const max = Math.max(...values); - this.#statAreaRanges[param] = { max, min }; - } - } - - /** * Initialize map with empty content - * @param {District[]} districts - * @param {Collection} coastLine - * @param {Collection} mainRoads - * @param {TrainTracks[]} tracks - * @param {TrainStation[]} stations - * @param {House[]} houses - * @param {StatisticalArea[]} statAreas - * @param {string} colorParameter + * @param {Collection} collection + * @param {HouseParameter} houseParameter + * @param {AreaParam} areaParameter * @returns {SVGSVGElement} */ - initialize(districts, coastLine, mainRoads, tracks, stations, houses, statAreas, colorParameter) { - this.#houses = houses; - this.#statAreas = statAreas; - this.#calculateStatAreaRanges(statAreas); - this.#setInitialViewBox(District.bounds(districts)); + initialize(collection, houseParameter, areaParameter) { + this.#collection = collection; + this.#setInitialViewBox(District.bounds(collection.districts)); const transformGroup = Svg.g( new SvgOptions({ attributes: { transform: "scale(1, -1)" }, @@ -261,12 +212,8 @@ export class MapEl { "pointer-events": "none", }, children: [ - ...MapEl.#getStatisticalAreas( - statAreas, - this.#areaColorParameter, - this.#statAreaRanges, - ), - ...MapEl.#getStatisticalAreaLabels(statAreas), + ...MapEl.#getStatisticalAreas(collection.statisticalAreas, areaParameter), + ...MapEl.#getStatisticalAreaLabels(collection.statisticalAreas), ], id: "statistical-areas", }), @@ -277,7 +224,10 @@ export class MapEl { "pointer-events": "none", "stroke-width": "0.0005", }, - children: [...MapEl.#getCoastLine(coastLine), ...MapEl.#getRoads(mainRoads)], + children: [ + ...MapEl.#getCoastLine(collection.coastLine), + ...MapEl.#getRoads(collection.mainRoads), + ], id: "background", }), ), @@ -289,7 +239,7 @@ export class MapEl { stroke: "rgba(255, 68, 68, 1)", "stroke-width": "0.001", }, - children: MapEl.#getTracks(tracks), + children: MapEl.#getTracks(collection.trainTracks), id: "train-tracks", }), ), @@ -302,14 +252,43 @@ export class MapEl { stroke: "rgba(204, 0, 0, 1)", "stroke-width": "0.001", }, - children: MapEl.#getStations(stations), + children: MapEl.#getStations(collection.trainStations), id: "train-stations", }), ), Svg.g( new SvgOptions({ + attributes: { + fill: "rgba(255, 255, 68, 1)", + "pointer-events": "none", + r: "0.003", + stroke: "rgba(255, 255, 0, 1)", + "stroke-width": "0.001", + }, + children: MapEl.#getStations(collection.lightRailStops), + id: "light_rail", + }), + ), + Svg.g( + new SvgOptions({ + attributes: { + fill: "rgba(0, 255, 68, 1)", + "pointer-events": "none", + r: "0.003", + stroke: "rgba(0, 255, 0, 1)", + "stroke-width": "0.001", + }, + children: MapEl.#renderFeatures(collection.jokerTramStops), + id: "tram-stations", + }), + ), + Svg.g( + new SvgOptions({ attributes: {}, - children: [...MapEl.#getDistricts(districts), ...MapEl.#getDistrictLabels(districts)], + children: [ + ...MapEl.#getDistricts(collection.districts), + ...MapEl.#getDistrictLabels(collection.districts), + ], id: "districts", }), ), @@ -322,7 +301,7 @@ export class MapEl { "stroke-linecap": "butt", "stroke-width": "0.001", }, - children: this.getHouses(houses, colorParameter), + children: this.#getHouses(collection.houses, houseParameter), id: "houses", }), ), @@ -332,8 +311,8 @@ export class MapEl { ); this.svg.append(transformGroup); - const coastBounds = Bounds.union(coastLine.features.map((f) => f.geometry.bounds())); - const roadBounds = Bounds.union(mainRoads.features.map((f) => f.geometry.bounds())); + const coastBounds = Bounds.union(collection.coastLine.features.map((f) => f.geometry.bounds())); + const roadBounds = Bounds.union(collection.mainRoads.features.map((f) => f.geometry.bounds())); this.#fullBounds = Bounds.union([coastBounds, roadBounds]); return this.svg; } @@ -641,25 +620,32 @@ export class MapEl { /** * Set houses data and render markers * @param {House[]} houses - * @param {ColorParameter} colorParameter + * @param {HouseParameter} param */ - getHouses(houses, colorParameter) { + #getHouses(houses, param) { + const values = houses.map((house) => house.get(param)).sort(); + const range = { max: Math.max(...values), min: Math.min(...values) }; + switch (param) { + case HouseParameter.price: // No prices available for each house. Take some from the bottom + range.min = values[Math.floor(values.length * 0.2)]; + range.max = values[Math.floor(values.length * 0.8)]; + } return houses.map((house) => { + const value = house.get(param); + const normalized = MapMath.normalize(value, range.min, range.max); const circle = Svg.circle( house.coordinates, new SvgOptions({ attributes: { "data-id": house.id, - fill: MapEl.#getHouseColor(house, colorParameter), + fill: Color.ocean(normalized), }, + children: [ + Svg.title(`${house.address}, ${house.district}\n€${house.price.toLocaleString()}`), + ], classes: ["house-marker"], }), ); - - const tooltipText = `${house.address}, ${house.district}\n€${house.price.toLocaleString()}`; - const title = Svg.title(tooltipText); - circle.appendChild(title); - circle.addEventListener("mouseenter", () => { circle.setAttribute("r", "0.005"); clearTimeout(this.#modalTimer); @@ -752,42 +738,38 @@ export class MapEl { /** * Set statistical areas data and render polygons * @param {StatisticalArea[]} statAreas - * @param {string} areaColorParameter - * @param {Object} ranges + * @param {string} paramName */ - static #getStatisticalAreas(statAreas, areaColorParameter, ranges) { + static #getStatisticalAreas(statAreas, paramName) { + const values = statAreas.map((area) => area.getValue(paramName)); + const range = { max: Math.max(...values), min: Math.min(...values) }; return statAreas.map((area) => { - const color = MapEl.#getStatisticalAreaColor(area, areaColorParameter, ranges); - const value = MapEl.#getStatisticalAreaValue(area, areaColorParameter); - - const poly = Svg.polygon( + const value = area.getValue(paramName); + const normalized = MapMath.normalize(value, range.min, range.max); + return Svg.polygon( area.polygon.simplify(30), new SvgOptions({ attributes: { "data-id": area.id, - fill: color, + fill: !(paramName === AreaParam.none) + ? Color.fall(normalized, true) + : "rgba(0, 0, 0, 0)", "pointer-events": "none", stroke: "rgba(0, 0, 0, 0.3)", "stroke-width": "0.0003", }, + children: [Svg.title(`${area.properties.nimi}\n${area.getDisplay(paramName)}`)], }), ); - - // Add tooltip with area name and value - const tooltipText = `${area.properties.nimi}\n${MapEl.#getStatisticalAreaDisplayText(areaColorParameter, value)}`; - const title = Svg.title(tooltipText); - poly.appendChild(title); - - return poly; }); } /** * Set statistical area labels - * @param {StatisticalArea[]} statAreas + * @param {StatisticalArea[]} areas */ - static #getStatisticalAreaLabels(statAreas) { - return statAreas.map((area) => { + static #getStatisticalAreaLabels(areas) { + return areas.map((area) => { const center = area.centroid; return Svg.text( center, @@ -807,72 +789,7 @@ export class MapEl { } /** - * Get color for statistical area based on parameter value - * @param {StatisticalArea} area - * @param {string} areaColorParameter - * @param {Object} ranges - * @returns {string} - */ - static #getStatisticalAreaColor(area, areaColorParameter, ranges) { - if (areaColorParameter === AreaColorParameter.none) { - return "rgba(0, 0, 0, 0)"; // Transparent - } - - const value = MapEl.#getStatisticalAreaValue(area, areaColorParameter); - const range = ranges[areaColorParameter]; - const normalized = range ? MapMath.normalize(value, range.min, range.max) : 0; - return Color.get("fall", normalized, true); - } - - /** - * Get value for statistical area based on parameter - * @param {StatisticalArea} area - * @param {string} areaColorParameter - * @returns {number} - */ - static #getStatisticalAreaValue(area, areaColorParameter) { - const props = area.properties; - - switch (areaColorParameter) { - case AreaColorParameter.foreignSpeakers: - return props.vr_kiel_vier / props.vr_vakiy; - case AreaColorParameter.unemploymentRate: - return props.tp_tyotaste; - case AreaColorParameter.averageIncome: - return props.tu_kesk; - case AreaColorParameter.higherEducation: - return props.ko_yl_kork / props.ko_25_; - default: - return 0; - } - } - - /** - * Get display text for statistical area tooltip - * @param {string} areaColorParameter - * @param {number} value - * @returns {string} - */ - static #getStatisticalAreaDisplayText(areaColorParameter, value) { - if (!(typeof value === "number")) { - return "NaN"; - } - switch (areaColorParameter) { - case AreaColorParameter.foreignSpeakers: - return `Foreign speakers: ${(value * 100).toFixed(1)}%`; - case AreaColorParameter.unemploymentRate: - return `Unemployment rate: ${value.toFixed(1)}%`; - case AreaColorParameter.averageIncome: - return `Average income: ${Math.round(value).toLocaleString()} €`; - case AreaColorParameter.higherEducation: - return `Higher education: ${(value * 100).toFixed(1)}%`; - default: - return ""; - } - } - - /** - * @param {Collection} roads + * @param {FeatureCollection} roads */ static #getRoads(roads) { return roads.features @@ -885,7 +802,7 @@ export class MapEl { } /** - * @param {Collection} coastline + * @param {FeatureCollection} coastline */ static #getCoastLine(coastline) { return coastline.features @@ -898,6 +815,24 @@ export class MapEl { } /** + * @param {FeatureCollection} c + */ + static #renderFeatures(c) { + return c.features + .map((feature) => { + if (feature.geometry instanceof MultiLineString) { + return Svg.path(new LineString(feature.geometry.simplify(30).coordinates.flat())); + } else if (feature.geometry instanceof LineString) { + return Svg.path(feature.geometry.simplify(30)); + } else if (feature.geometry instanceof Point) { + return Svg.circle(feature.geometry); + } + return null; + }) + .filter((x) => x !== null); + } + + /** * Set train infrastructure data * @param {TrainTracks[]} tracks */ @@ -925,42 +860,48 @@ export class MapEl { /** * Update house colors based on current color parameter - * @param {ColorParameter} colorParameter + * @param {HouseParameter} param */ - setColorParameter(colorParameter) { + updateHousesColor(param) { + const values = this.#collection?.houses.map((house) => house.get(param)).sort(); + const range = { max: Math.max(...values), min: Math.min(...values) }; + switch (param) { + case HouseParameter.price: // No prices available for each house. Take some from the bottom + range.min = values[Math.floor(values.length * 0.2)]; + range.max = values[Math.floor(values.length * 0.8)]; + } const markers = this.#housesGroup?.querySelectorAll(".house-marker"); markers?.forEach((marker) => { const houseId = marker.id; - const house = this.#houses.find((h) => h.id === houseId); + const house = this.#collection?.houses.find((h) => h.id === houseId); if (house) { - const color = MapEl.#getHouseColor(house, colorParameter); - marker.setAttribute("fill", color); + const value = house.get(param); + const normalized = MapMath.normalize(value, range.min, range.max); + marker.setAttribute("fill", Color.ocean(normalized)); } }); } /** * Update statistical area colors based on current area color parameter - * @param {string} areaColorParameter + * @param {AreaParam} param */ - setAreaColorParameter(areaColorParameter) { - this.#areaColorParameter = areaColorParameter; - + updateArea(param) { + const values = this.#collection?.statisticalAreas.map((area) => area.getValue(param)); + const range = { max: Math.max(...values), min: Math.min(...values) }; const statAreaPolygons = this.svg.querySelectorAll("#statistical-areas polygon"); statAreaPolygons.forEach((polygon) => { const areaId = polygon.getAttribute("data-id"); - const area = this.#statAreas.find((a) => a.id === areaId); + const area = this.#collection?.statisticalAreas.find((a) => a.id === areaId); if (area) { - const color = MapEl.#getStatisticalAreaColor( - area, - areaColorParameter, - this.#statAreaRanges, + const value = area.getValue(param); + const normalized = MapMath.normalize(value, range.min, range.max); + polygon.setAttribute( + "fill", + !(param === AreaParam.none) ? Color.fall(normalized, true) : "rgba(0, 0, 0, 0)", ); - polygon.setAttribute("fill", color); - // Update tooltip - const value = MapEl.#getStatisticalAreaValue(area, areaColorParameter); - const tooltipText = `${area.properties.nimi}\n${MapEl.#getStatisticalAreaDisplayText(areaColorParameter, value)}`; + const tooltipText = `${area.properties.nimi}\n${area.getDisplay(param)}`; const title = polygon.querySelector("title"); if (title) { title.textContent = tooltipText; @@ -997,44 +938,6 @@ export class MapEl { clearModalTimer() { clearTimeout(this.#modalTimer); } - - /** - * Get color for house based on parameter value - * @param {House} house - * @param {ColorParameter} colorParameter - * @returns {string} - */ - static #getHouseColor(house, colorParameter) { - let value, min, max; - - switch (colorParameter) { - case ColorParameter.price: - value = house.price; - min = 0; - max = 1000000; - break; - case ColorParameter.score: - value = house.scores.current; - min = 0; - max = 100; - break; - case ColorParameter.year: - value = house.constructionYear || 1950; - min = 1950; - max = new Date().getFullYear(); - break; - case ColorParameter.area: - value = house.livingArea; - min = 0; - max = 200; - break; - default: - return "rgba(76, 175, 80, 1)"; - } - - const normalized = MapMath.normalize(value, min, max); - return Color.get("ocean", normalized); - } } /** @@ -1043,30 +946,55 @@ export class MapEl { */ export class Color { /** - * Get color from specified colormap - * @param {string} colormap - Name of colormap ('fall', 'ocean', 'bubblegum', 'lilac') * @param {number} value - Normalized value between 0 and 1 * @param {boolean} [reverse=false] - Reverse the colormap * @returns {string} RGBA color string */ - static get(colormap, value, reverse = false) { + static fall(value, reverse = false) { + if (Number.isNaN(value) || value > 1 || value < 0) { + throw new Error(`Fall, input must be a number between [0,1], got ${value}`); + } + const normalizedT = reverse ? 1 - value : value; + return Color.#fall(normalizedT); + } + + /** + * @param {number} value - Normalized value between 0 and 1 + * @param {boolean} [reverse=false] - Reverse the colormap + * @returns {string} RGBA color string + */ + static ocean(value, reverse = false) { + if (Number.isNaN(value) || value > 1 || value < 0) { + throw new Error(`Ocean, input must be a number between [0,1], got ${value}`); + } + const normalizedT = reverse ? 1 - value : value; + return Color.#ocean(normalizedT); + } + + /** + * @param {number} value - Normalized value between 0 and 1 + * @param {boolean} [reverse=false] - Reverse the colormap + * @returns {string} RGBA color string + */ + static lilac(value, reverse = false) { if (Number.isNaN(value) || value > 1 || value < 0) { - throw new Error(`Input must be a number between [0,1] ${value}`); + throw new Error(`Ocean, input must be a number between [0,1], got ${value}`); } const normalizedT = reverse ? 1 - value : value; + return Color.#lilac(normalizedT); + } - switch (colormap.toLowerCase()) { - case "fall": - return Color.#fall(normalizedT); - case "ocean": - return Color.#ocean(normalizedT); - case "bubblegum": - return Color.#bubblegum(normalizedT); - case "lilac": - return Color.#lilac(normalizedT); - default: - throw new Error(`Unknown colormap: ${colormap}`); + /** + * @param {number} value - Normalized value between 0 and 1 + * @param {boolean} [reverse=false] - Reverse the colormap + * @returns {string} RGBA color string + */ + static bubblegum(value, reverse = false) { + if (Number.isNaN(value) || value > 1 || value < 0) { + throw new Error(`Ocean, input must be a number between [0,1], got ${value}`); } + const normalizedT = reverse ? 1 - value : value; + return Color.#bubblegum(normalizedT); } /** @@ -1185,42 +1113,4 @@ export class Color { return `rgba(${Math.round(r * 255)}, ${Math.round(g * 255)}, ${Math.round(b * 255)}, 1)`; } - - /** - * Get all available colormap names - * @returns {string[]} Array of colormap names - */ - static getColormapNames() { - return ["fall", "ocean", "bubblegum", "lilac"]; - } - - /** - * Generate a color scale for testing/visualization - * @param {string} colormap - Name of colormap - * @param {number} steps - Number of steps in the scale - * @param {boolean} [reverse=false] - Reverse the colormap - * @returns {string[]} Array of RGBA colors - */ - static generateColorScale(colormap, steps = 10, reverse = false) { - const colors = []; - for (let i = 0; i < steps; i++) { - const t = i / (steps - 1); - colors.push(Color.get(colormap, t, reverse)); - } - return colors; - } - - /** - * Get color with custom alpha value - * @param {string} colormap - Name of colormap - * @param {number} value - Normalized value between 0 and 1 - * @param {number} alpha - Alpha value between 0 and 1 - * @param {boolean} [reverse=false] - Reverse the colormap - * @returns {string} RGBA color string - */ - static getColorWithAlpha(colormap, value, alpha, reverse = false) { - const color = Color.get(colormap, value, reverse); - // Replace the alpha value in the rgba string - return color.replace(/[\d.]+\)$/, `${alpha})`); - } } diff --git a/app/models.js b/app/models.js index b100ac8..9ab9c84 100644 --- a/app/models.js +++ b/app/models.js @@ -1,4 +1,4 @@ -import { Bounds, Collection, Feature, Geometry, LineString, Point, Polygon } from "geom"; +import { Bounds, Feature, FeatureCollection, Geometry, LineString, Point, Polygon } from "geom"; /** @typedef {{ lat: number, lng: number }} GeoPointJson */ /** @typedef {{ date: string, price: number }} PriceUpdateJson */ @@ -271,6 +271,18 @@ export class Geospatial { } /** + * Area color parameters for statistical areas + * @enum {string} + */ +export const AreaParam = { + averageIncome: "averageIncome", + foreignSpeakers: "foreignSpeakers", + higherEducation: "higherEducation", + none: "none", + unemploymentRate: "unemploymentRate", +}; + +/** * Represents a statistical area with demographic and housing data */ export class StatisticalArea { @@ -283,6 +295,50 @@ export class StatisticalArea { this.id = id; this.polygon = polygon; this.properties = properties; + this.averageIncome = properties.tu_kesk; + this.foreignSpeakers = properties.vr_kiel_vier / properties.vr_vakiy; + this.higherEducation = properties.ko_yl_kork / properties.ko_25_; + this.unemploymentRate = properties.tp_tyotaste; + } + + /** + * Get display text for statistical area tooltip + * @param {AreaParam} param + * @returns {string} + */ + getDisplay(param) { + switch (param) { + case AreaParam.foreignSpeakers: + return `Foreign speakers: ${(this.foreignSpeakers * 100).toFixed(1)}%`; + case AreaParam.unemploymentRate: + return `Unemployment rate: ${this.unemploymentRate?.toFixed(1)}%`; + case AreaParam.averageIncome: + return `Average income: ${Math.round(this.averageIncome).toLocaleString()} €`; + case AreaParam.higherEducation: + return `Higher education: ${(this.higherEducation * 100).toFixed(1)}%`; + default: + return ""; + } + } + + /** + * Get value for statistical area based on parameter + * @param {string} param + * @returns {number} + */ + getValue(param) { + switch (param) { + case AreaParam.foreignSpeakers: + return this.foreignSpeakers; + case AreaParam.unemploymentRate: + return this.unemploymentRate; + case AreaParam.averageIncome: + return this.averageIncome; + case AreaParam.higherEducation: + return this.higherEducation; + default: + return 0; + } } /** @@ -299,11 +355,10 @@ export class StatisticalArea { } /** - * Convert Collection to StatisticalArea[] - * @param {Collection} collection + * @param {FeatureCollection} collection * @returns {StatisticalArea[]} */ - static fromCollection(collection) { + static fromFeatureCollection(collection) { return collection.features.map(StatisticalArea.fromFeature); } @@ -380,11 +435,11 @@ export class District { } /** - * Convert Collection to District[] - * @param {Collection} collection + * Convert FeatureCollection to District[] + * @param {FeatureCollection} collection * @returns {District[]} */ - static fromCollection(collection) { + static fromFeatureCollection(collection) { return collection.features.map(District.fromFeature); } @@ -421,11 +476,11 @@ export class TrainTracks { } /** - * Convert Collection to TrainTracks[] - * @param {Collection} collection + * Convert FeatureCollection to TrainTracks[] + * @param {FeatureCollection} collection * @returns {TrainTracks[]} */ - static fromCollection(collection) { + static fromFeatureCollection(collection) { return collection.features.map(TrainTracks.fromFeature); } } @@ -454,15 +509,26 @@ export class TrainStation { } /** - * Convert Collection to TrainStation[] - * @param {Collection} collection + * Convert FeatureCollection to TrainStation[] + * @param {FeatureCollection} collection * @returns {TrainStation[]} */ - static fromCollection(collection) { + static fromFeatureCollection(collection) { return collection.features.map(TrainStation.fromFeature).filter((x) => x !== null); } } +/** + * Color parameters for house markers + * @enum {string} + */ +export const HouseParameter = { + area: "livingArea", + price: "price", + score: "score", + year: "constructionYear", +}; + export class House { /** * @param {string} id @@ -511,6 +577,7 @@ export class House { scores = new Scores(0), images = [], geospatial = new Geospatial(), + value = 0, ) { this.id = id; this.address = address; @@ -534,9 +601,27 @@ export class House { this.scores = scores; this.images = images; this.geospatial = geospatial; + this.value = value; } - /** @param {HouseJson} data @returns {House} */ + /** @param {HouseParameter} param */ + get(param) { + switch (param) { + case HouseParameter.price: + return this.price; + case HouseParameter.score: + return this.value; + case HouseParameter.year: + return this.constructionYear; + case HouseParameter.area: + return this.livingArea; + default: + return 0; + } + } + + /** @param {HouseJson} data + @returns {House} */ static fromJson(data) { // Parse price from string like "260 000 €" to number /** @param {string} priceStr @returns {number} */ @@ -658,13 +743,102 @@ export class Filters { constructor() { this.minPrice = 0; this.maxPrice = Number.POSITIVE_INFINITY; - this.minYear = 0; + this.minYear = 1800; this.minArea = 0; /** @type {string[]} */ this.districts = []; } } +export class Collection { + /** + * @param {District[]} districts + * @param {House[]} houses + * @param {TrainStation[]} trainStations * + * @param {TrainTracks[]} trainTracks + * @param {FeatureCollection} coastLine + * @param {FeatureCollection} mainRoads + * @param {StatisticalArea[]} statisticalAreas + * @param {FeatureCollection} jokerTramStops, + * @param {FeatureCollection} jokerTramTracks, + * @param {TrainStation[]} lightRailStops, + * @param {TrainTracks[]} lightRailTracks, + */ + constructor( + districts, + houses, + trainStations, + trainTracks, + coastLine, + mainRoads, + statisticalAreas, + jokerTramStops, + //jokerTramTracks, + lightRailStops, + lightRailTracks, + ) { + this.districts = districts; + this.houses = houses; + this.trainStations = trainStations; + this.trainTracks = trainTracks; + this.coastLine = coastLine; + this.mainRoads = mainRoads; + this.statisticalAreas = statisticalAreas; + this.jokerTramStops = jokerTramStops; + //this.jokerTramTracks = jokerTramTracks; + this.lightRailStops = lightRailStops; + this.lightRailTracks = lightRailTracks; + } + + static async get() { + const [ + districts, + houses, + trainStations, + trainTracks, + coastLine, + mainRoads, + statisticalAreas, + jokerTramStops, + //jokerTramTracks, + lightRailStops, + lightRailTracks, + ] = await Promise.all([ + await DataProvider.getDistricts(), + await DataProvider.getHouses(), + await DataProvider.getTrainStations(), + await DataProvider.getTrainTracks(), + await DataProvider.getCoastline(), + await DataProvider.getMainRoads(), + await DataProvider.getStatisticalAreas(), + await DataProvider.getJokerTramStops(), + //await DataProvider.getJokerTramTracks(), + await DataProvider.getLightRailStops(), + await DataProvider.getLightRailTracks(), + ]); + return new Collection( + districts, + houses, + trainStations, + trainTracks, + coastLine, + mainRoads, + statisticalAreas, + jokerTramStops, + //jokerTramTracks, + lightRailStops, + lightRailTracks, + ); + } + + /** + * @param {Point} point + */ + static getDistanceToRail(point) { + return 0; + } +} + export class DataProvider { static couchBaseUrl = "https://couch.tammi.cc"; static wfsDbName = "helsinki_wfs"; @@ -673,9 +847,9 @@ export class DataProvider { /** * Fetch all features for a layer as a GeoJSON FeatureCollection * @param {string} layerName - * @returns {Promise<Collection>} + * @returns {Promise<FeatureCollection>} */ - static async getCollectionFromCouch(layerName) { + static async getFeaturesFromCouch(layerName) { // Use CouchDB view to get all features for the layer const viewUrl = new URL( `/${DataProvider.wfsDbName}/_design/layers/_view/by_layer`, @@ -705,42 +879,69 @@ export class DataProvider { : null; }) .filter((x) => x !== null); - return new Collection(features); + return new FeatureCollection(features); } - /** @returns {Promise<Collection>} */ + /** @returns {Promise<FeatureCollection>} */ static async getCoastline() { - return await DataProvider.getCollectionFromCouch("Seutukartta_meren_rantaviiva"); + return await DataProvider.getFeaturesFromCouch("Seutukartta_meren_rantaviiva"); } - /** @returns {Promise<Collection>} */ + /** @returns {Promise<FeatureCollection>} */ static async getMainRoads() { - return await DataProvider.getCollectionFromCouch("Seutukartta_liikenne_paatiet"); + return await DataProvider.getFeaturesFromCouch("Seutukartta_liikenne_paatiet"); } /** @returns {Promise<District[]>} */ static async getDistricts() { - const collection = await DataProvider.getCollectionFromCouch("Piirijako_peruspiiri"); - const districts = District.fromCollection(collection); - return districts; + return District.fromFeatureCollection( + await DataProvider.getFeaturesFromCouch("Piirijako_peruspiiri"), + ); } /** @returns {Promise<TrainStation[]>} */ static async getTrainStations() { - const collection = await DataProvider.getCollectionFromCouch("Seutukartta_liikenne_juna_asema"); - return TrainStation.fromCollection(collection); + return TrainStation.fromFeatureCollection( + await DataProvider.getFeaturesFromCouch("Seutukartta_liikenne_juna_asema"), + ); } /** @returns {Promise<TrainTracks[]>} */ static async getTrainTracks() { - const collection = await DataProvider.getCollectionFromCouch("Seutukartta_liikenne_juna_rata"); - return TrainTracks.fromCollection(collection); + return TrainTracks.fromFeatureCollection( + await DataProvider.getFeaturesFromCouch("Seutukartta_liikenne_juna_rata"), + ); + } + + /** @returns {Promise<TrainStation[]>} */ + static async getLightRailStops() { + return TrainStation.fromFeatureCollection( + await DataProvider.getFeaturesFromCouch("Seutukartta_liikenne_metroasemat"), + ); + } + + /** @returns {Promise<TrainTracks[]>} */ + static async getLightRailTracks() { + return TrainTracks.fromFeatureCollection( + await DataProvider.getFeaturesFromCouch("Seutukartta_liikenne_metro_rata"), + ); + } + + /** @returns {Promise<FeatureCollection>} */ + static async getJokerTramStops() { + return await DataProvider.getFeaturesFromCouch("RaideJokeri_pysakit"); + } + + /** @returns {Promise<FeatureCollection>} */ + static async getJokerTramTracks() { + return await DataProvider.getFeaturesFromCouch("RaideJokeri_ratalinja"); } /** @returns {Promise<StatisticalArea[]>} */ static async getStatisticalAreas() { - const collection = await DataProvider.getCollectionFromCouch("Aluesarjat_avainluvut_2024"); - return StatisticalArea.fromCollection(collection); + return StatisticalArea.fromFeatureCollection( + await DataProvider.getFeaturesFromCouch("Aluesarjat_avainluvut_2024"), + ); } /** @returns {Promise<House[]>} */ @@ -762,7 +963,7 @@ export class DataProvider { return []; } - const data = /** @type {ApiResponse} */ (await response.json()); + const data = await response.json(); const housesData = data.rows.map((row) => row.doc).filter((doc) => doc?.raw?.location); console.log(`Loaded ${housesData.length} houses from API`); @@ -774,13 +975,12 @@ export class DataProvider { } /** - * Convert houses to Feature Collection for export * @param {House[]} houses - * @returns {Collection} + * @returns {FeatureCollection} */ static housesToCollection(houses) { const features = houses.map((house) => house.toFeature()); - return new Collection(features); + return new FeatureCollection(features); } } diff --git a/download.js b/download.js index 6a1e67e..173af62 100644 --- a/download.js +++ b/download.js @@ -12,7 +12,9 @@ const couchPassword = process.env.COUCHDB_PASSWORD; */ function getAuthHeader() { if (!couchUsername || !couchPassword) { - throw new Error("CouchDB credentials not set in environment variables"); + throw new Error( + "CouchDB credentials COUCHDB_USERNAME COUCHDB_PASSWORD not set in environment variables", + ); } const auth = Buffer.from(`${couchUsername}:${couchPassword}`).toString("base64"); return `Basic ${auth}`; @@ -21,15 +23,17 @@ function getAuthHeader() { const baseUrl = "https://kartta.hel.fi/ws/geoserver/avoindata/wfs"; const layers = [ "Aluesarjat_avainluvut_2024", - "Piirijako_pienalue", "Piirijako_peruspiiri", - "Seutukartta_liikenne_paatiet", - "Seutukartta_liikenne_metroasemat", - "Seutukartta_liikenne_metro_rata", - "Seutukartta_liikenne_juna_rata", - "Seutukartta_liikenne_juna_asema", - "Seutukartta_aluejako_pienalue", + "Piirijako_pienalue", + "RaideJokeri_pysakit", + "RaideJokeri_ratalinja", "Seutukartta_aluejako_kuntarajat", + "Seutukartta_aluejako_pienalue", + "Seutukartta_liikenne_juna_asema", + "Seutukartta_liikenne_juna_rata", + "Seutukartta_liikenne_metro_rata", + "Seutukartta_liikenne_metroasemat", + "Seutukartta_liikenne_paatiet", "Seutukartta_maankaytto_jarvet", "Seutukartta_maankaytto_joet", "Seutukartta_meren_rantaviiva", |
