aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--app/components.js58
-rw-r--r--app/dom.js12
-rw-r--r--app/geometry.js8
-rw-r--r--app/main.js209
-rw-r--r--app/map.js456
-rw-r--r--app/models.js270
-rw-r--r--download.js20
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;
diff --git a/app/dom.js b/app/dom.js
index 508636a..4419205 100644
--- a/app/dom.js
+++ b/app/dom.js
@@ -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;
}
}
diff --git a/app/map.js b/app/map.js
index 2259b60..2c95fb9 100644
--- a/app/map.js
+++ b/app/map.js
@@ -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",