import { Bounds, Feature, FeatureCollection, Geometry, LineString, Point, Polygon } from "geom"; /** @typedef {{ lat: number, lng: number }} GeoPointJson */ /** @typedef {{ date: string, price: number }} PriceUpdateJson */ /** @typedef {{ date: string, score: number }} HistoricalScoreJson */ /** @typedef {{ current: number, historical?: HistoricalScoreJson[] }} ScoresJson */ /** * Geospatial data – all fields optional, defaults provided * @typedef {Object} GeospatialJson * @property {number} [marketDistance] * @property {number} [schoolDistance] * @property {number} [crimeRate] * @property {number} [safetyIndex] * @property {number} [s2StudentRatio] * @property {number} [railwayDistance] */ /** * Raw data structure from API * @typedef {Object} RawDataJson * @property {string} [description] * @property {number} [rooms] * @property {string} [roomConfiguration] * @property {string} price * @property {string} size * @property {number} [buildYear] * @property {number} [sizeLot] * @property {number} [sizeMin] * @property {number} [sizeMax] * @property {string} [nextViewing] * @property {boolean} [newDevelopment] * @property {boolean} [isOnlineOffer] * @property {boolean} [extraVisibility] * @property {number} [visits] * @property {number} [visitsWeekly] * @property {string} [securityDeposit] * @property {string} [maintenanceFee] * @property {string} [floor] * @property {number} [buildingFloorCount] * @property {number} [pricePerSqm] * @property {string} [condition] * @property {number} [sourceType] */ /** * Location data from API * @typedef {Object} LocationJson * @property {string} address * @property {string} district * @property {string} city * @property {string} zipCode * @property {string} country * @property {number} latitude * @property {number} longitude */ /** * House JSON from API - actual structure * @typedef {Object} HouseJson * @property {string} _id * @property {string} _rev * @property {string} source * @property {string} url * @property {number} status * @property {number} type * @property {number} subType * @property {string[]} images * @property {Object} raw * @property {RawDataJson} raw.data * @property {LocationJson} raw.location * @property {string} scraped_at */ /** * Statistical Area Properties JSON structure * @typedef {Object} StatisticalAreaPropertiesJson * @property {number} id * @property {number} osa_alueid * @property {string} nimi * @property {string} namn * @property {number} kokotun * @property {number} vuosi * @property {number} vr_vakiy * @property {number} vr_0_2 * @property {number} vr_3_6 * @property {number} vr_7_12 * @property {number} vr_13_15 * @property {number} vr_16_17 * @property {number} vr_18_19 * @property {number} vr_20_24 * @property {number} vr_25_29 * @property {number} vr_30_34 * @property {number} vr_35_39 * @property {number} vr_40_44 * @property {number} vr_45_49 * @property {number} vr_50_54 * @property {number} vr_55_59 * @property {number} vr_60_64 * @property {number} vr_65_69 * @property {number} vr_70_74 * @property {number} vr_75_79 * @property {number} vr_80_84 * @property {number} vr_85_ * @property {number} vr_kiel_su_sa * @property {number} vr_kiel_ru * @property {number} vr_kiel_vier * @property {number} vm_synt * @property {number} vm_kuol * @property {number} vm_mu_tulo * @property {number} vm_mu_lahto * @property {number} vm_s_mu_tulo * @property {number} vm_s_mu_lahto * @property {number} ko_25_ * @property {number} ko_tut_yht * @property {number} ko_toinen * @property {number} ko_al_kork * @property {number} ko_yl_kork * @property {number} ko_perus * @property {number} tu_kesk * @property {number} tu_ask_lkm * @property {number} tu_pien * @property {number} tu_med * @property {string} ap_ask_lkm * @property {string} ap_yhd_ask * @property {string} ap_lper * @property {number} ap_lper_l1 * @property {number} ap_lper_l2 * @property {number} ap_lper_l3 * @property {number} ap_lper_l4 * @property {string} ap_per_yht * @property {number} ap_yhd_vanh * @property {string} ra_rak * @property {string} ra_rak_ker * @property {string} ra_asrak * @property {number} ra_as * @property {number} ra_as_om * @property {number} ra_as_vu * @property {number} ra_as_asoik * @property {number} ra_as_muu * @property {number} ra_pt_as * @property {number} ra_kt_as * @property {number} ra_hu_1 * @property {number} ra_hu_2 * @property {number} ra_hu_3 * @property {number} ra_hu_4 * @property {number} ra_hu_5 * @property {number} ra_hu_6 * @property {number} ra_hu_muu * @property {number} tp_tyopy * @property {number} tp_a * @property {number} tp_b * @property {number} tp_c * @property {number} tp_d * @property {number} tp_e * @property {number} tp_f * @property {number} tp_g * @property {number} tp_h * @property {number} tp_i * @property {number} tp_j * @property {number} tp_k * @property {number} tp_l * @property {number} tp_m * @property {number} tp_n * @property {number} tp_o * @property {number} tp_p * @property {number} tp_q * @property {number} tp_r * @property {number} tp_s * @property {number} tp_t * @property {number} tp_u * @property {number} tp_x * @property {number} tp_asuky * @property {number} tp_ * @property {number} tp_tyol * @property {number} tp_tyot * @property {number} tp_tyov_ulk * @property {number} tp_0_14 * @property {number} tp_opisk_var * @property {number} tp_elak * @property {number} tp_tyotaste * @property {number|null} vr_enn_2037 * @property {number} vr_enn * @property {string|null} paivitetty_tietopalveluun */ /** * Statistical Area JSON from CouchDB * @typedef {Object} StatisticalAreaJson * @property {string} _id * @property {string} _rev * @property {string} downloaded_at * @property {Object} geometry * @property {string} layer * @property {StatisticalAreaPropertiesJson} properties * @property {string} type */ export class PriceUpdate { /** @param {Date} date @param {number} price */ constructor(date, price) { this.date = date; this.price = price; } /** @param {PriceUpdateJson} data @returns {PriceUpdate} */ static fromJson(data) { return new PriceUpdate(new Date(data.date), data.price); } } export class HistoricalScore { /** @param {Date} date @param {number} score */ constructor(date, score) { this.date = date; this.score = score; } /** @param {HistoricalScoreJson} data @returns {HistoricalScore} */ static fromJson(data) { return new HistoricalScore(new Date(data.date), data.score); } } export class Scores { /** @param {number} current @param {HistoricalScore[]} [historical] */ constructor(current, historical = []) { this.current = current; this.historical = historical; } /** @param {ScoresJson} data @returns {Scores} */ static fromJson(data) { return new Scores(data.current ?? 0, (data.historical ?? []).map(HistoricalScore.fromJson)); } } export class Geospatial { /** * @param {number} [marketDistance] * @param {number} [schoolDistance] * @param {number} [railwayDistance] */ constructor( marketDistance = Number.POSITIVE_INFINITY, schoolDistance = Number.POSITIVE_INFINITY, railwayDistance = Number.POSITIVE_INFINITY, ) { this.marketDistance = marketDistance; this.schoolDistance = schoolDistance; this.railwayDistance = railwayDistance; this.distanceTrain = Number.POSITIVE_INFINITY; this.distanceLightRail = Number.POSITIVE_INFINITY; this.distanceTram = Number.POSITIVE_INFINITY; } /** @param {GeospatialJson} data @returns {Geospatial} */ static fromJson(data) { return new Geospatial( data.marketDistance ?? 0, data.schoolDistance ?? 0, data.railwayDistance ?? 0, ); } } /** * 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 { /** * @param {string} id * @param {Polygon} polygon * @param {StatisticalAreaPropertiesJson} properties */ constructor(id, polygon, properties) { 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; } } /** * @param {Feature} feature * @returns {StatisticalArea} */ static fromFeature(feature) { const geometry = feature.geometry; if (!(geometry instanceof Polygon)) { throw new Error(`Invalid statistical area feature data ${geometry}`); } return new StatisticalArea(feature.id, geometry, feature.properties); } /** * @param {FeatureCollection} collection * @returns {StatisticalArea[]} */ static fromFeatureCollection(collection) { return collection.features.map(StatisticalArea.fromFeature); } /** * Check if point is within this statistical area * @param {Point} point * @returns {boolean} */ contains(point) { return this.polygon.within(point) || this.polygon.intersects(point); } /** * Get the centroid of the statistical area * @returns {Point} */ get centroid() { return this.polygon.centroid(); } } /** * Represents a geographic district with name and polygon */ export class District { /** * @param {string} name * @param {string} municipality * @param {Polygon} polygon */ constructor(name, municipality, polygon) { this.name = name; this.municipality = municipality; this.polygon = polygon; } /** * @param {Feature} feature * @returns {District} */ static fromFeature(feature) { const name = "nimi_fi" in feature.properties && typeof feature.properties.nimi_fi === "string" ? feature.properties.nimi_fi : ""; const municipality = "kunta" in feature.properties && typeof feature.properties.kunta === "string" ? feature.properties.kunta : ""; const geometry = feature.geometry; if (!(geometry instanceof Polygon)) { throw new Error(`Invalid district feature data ${geometry}`); } return new District(name, municipality, geometry); } /** * Set houses data and render markers * @param {District[]} districts */ static bounds(districts) { const bounds = new Bounds(Infinity, Infinity, -Infinity, -Infinity); // Include districts in bounds for (const district of districts) { const districtBounds = district.polygon.bounds(); bounds.minX = Math.min(districtBounds.minX, bounds.minX); bounds.minY = Math.min(districtBounds.minY, bounds.minY); bounds.maxX = Math.max(districtBounds.maxX, bounds.maxX); bounds.maxY = Math.max(districtBounds.maxY, bounds.maxY); } return bounds; } /** * Convert FeatureCollection to District[] * @param {FeatureCollection} collection * @returns {District[]} */ static fromFeatureCollection(collection) { return collection.features.map(District.fromFeature); } /** * Check if point is within this district * @param {Point} point * @returns {boolean} */ contains(point) { return this.polygon.within(point) || this.polygon.intersects(point); } } /** * Represents train tracks with linestring geometry */ export class TrainTracks { /** * @param {LineString} lineString */ constructor(lineString) { this.lineString = lineString; } /** * @param {Feature} feature * @returns {TrainTracks} */ static fromFeature(feature) { if (!(feature.geometry instanceof LineString)) { throw new Error("Invalid train tracks feature data {feature}"); } return new TrainTracks(feature.geometry); } /** * Convert FeatureCollection to TrainTracks[] * @param {FeatureCollection} collection * @returns {TrainTracks[]} */ static fromFeatureCollection(collection) { return collection.features.map(TrainTracks.fromFeature); } } /** * Represents train stations with polygon geometry */ export class TrainStation { /** * @param {Polygon} polygon */ constructor(polygon) { this.polygon = polygon; } /** * @param {Feature} feature * @returns {TrainStation|null} */ static fromFeature(feature) { if (!(feature.geometry instanceof Polygon)) { throw new Error("Invalid train stations feature data {feature}"); } return new TrainStation(feature.geometry); } /** * Convert FeatureCollection to TrainStation[] * @param {FeatureCollection} collection * @returns {TrainStation[]} */ 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 * @param {string} address * @param {string} district * @param {Point} coordinates * @param {number} price * @param {string} [postalCode] * @param {string} buildingType * @param {number} [constructionYear] * @param {number} [rooms] * @param {number} livingArea * @param {number} [totalArea] * @param {number} [floorCount] * @param {string} [condition] * @param {string} [description] * @param {PriceUpdate[]} [priceHistory] * @param {Date|null} firstSeen * @param {Date|null} lastSeen * @param {Date|null} lastUpdated * @param {Date|null} [disappeared] * @param {Scores} scores * @param {string[]} images * @param {Geospatial} [geospatial] * @param {StatisticalArea|null} statisticalArea * @param {string} url */ constructor( id, address, district, coordinates, price, postalCode = "", buildingType = "", constructionYear = 0, rooms = 0, livingArea = 0, totalArea = 0, floorCount = 1, condition = "", description = "", priceHistory = [], firstSeen = null, lastSeen = null, lastUpdated = null, disappeared = null, scores = new Scores(0), images = [], geospatial = new Geospatial(), value = 0, statisticalArea = null, url = "", pricePerSqm = 0, ) { this.id = id; this.address = address; this.district = district; this.coordinates = coordinates; this.price = price; this.postalCode = postalCode; this.buildingType = buildingType; this.constructionYear = constructionYear; this.rooms = rooms; this.livingArea = livingArea; this.totalArea = totalArea; this.floorCount = floorCount; this.condition = condition; this.description = description; this.priceHistory = priceHistory; this.firstSeen = firstSeen; this.lastSeen = lastSeen; this.lastUpdated = lastUpdated; this.disappeared = disappeared; this.scores = scores; this.images = images; this.geospatial = geospatial; this.value = value; this.statisticalArea = statisticalArea; this.url = url; this.pricePerSqm = pricePerSqm; } /** @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} */ const parsePrice = (priceStr) => { if (!priceStr) return 0; // Remove spaces, euro sign and any non-digit characters except decimal point const cleanPrice = priceStr.replace(/[^\d,]/g, "").replace(",", "."); return parseFloat(cleanPrice) || 0; }; // Parse area from string like "800 m²" to number /** @param {string} areaStr @returns {number} */ const parseArea = (areaStr) => { if (!areaStr) return 0; const match = areaStr.match(/([\d,]+)\s*m²/); return match ? parseFloat(match[1].replace(",", ".")) : 0; }; const raw = data.raw || {}; const rawData = raw.data || {}; const rawLocation = raw.location || {}; // Create Point from longitude and latitude const coordinates = new Point(rawLocation.longitude || 0, rawLocation.latitude || 0); return new House( data._id, rawLocation.address || "", rawLocation.district || "", coordinates, parsePrice(rawData.price), rawLocation.zipCode || "", House.getBuildingType(data.type), rawData.buildYear || 0, rawData.rooms || 0, rawData.sizeMin || parseArea(rawData.size) || 0, rawData.sizeLot || 0, rawData.buildingFloorCount || 1, rawData.condition || "", rawData.description || "", [], new Date(data.scraped_at), new Date(data.scraped_at), new Date(data.scraped_at), null, new Scores(0), data.images || [], new Geospatial(), 0, null, data.url || "", rawData.pricePerSqm || 0, ); } /** * Map type and subType to building type string * @param {number} type * @returns {string} */ static getBuildingType(type) { // Basic mapping - can be expanded based on actual type values /** @type {{[key: number]: string}} */ const typeMap = { 100: "House", 200: "Apartment", 300: "Summer cottage", }; return typeMap[type] || "Unknown"; } /** * @param {Filters} filters * @returns {boolean} */ matchesFilters(filters) { if (filters.minPrice > 0 && this.price < filters.minPrice) return false; if (filters.maxPrice < Infinity && this.price > filters.maxPrice) return false; if (filters.minYear > 0 && this.constructionYear < filters.minYear) return false; if (filters.minArea > 0 && this.livingArea < filters.minArea) return false; if (filters.districts.length > 0 && !filters.districts.includes(this.district)) return false; return true; } } export class Filters { /** * @param {House[]} houses */ constructor(houses) { this.minPrice = 0; this.maxPrice = Number.POSITIVE_INFINITY; this.minYear = 1800; this.maxYear = Number.POSITIVE_INFINITY; this.minArea = 0; this.maxArea = Number.POSITIVE_INFINITY; this.minLot = 0; this.maxLot = Number.POSITIVE_INFINITY; this.updateRanges(houses); /** @type {string[]} */ this.districts = []; } reset() { this.minPrice = 0; this.maxPrice = Number.POSITIVE_INFINITY; this.minYear = 1800; this.maxYear = Number.POSITIVE_INFINITY; this.minArea = 0; this.maxArea = Number.POSITIVE_INFINITY; this.minLot = 0; this.maxLot = Number.POSITIVE_INFINITY; this.districts = []; } /** * Update filter ranges based on house data * @param {House[]} houses */ updateRanges(houses) { const prices = houses.map((h) => h.price).filter((p) => p > 0); const years = houses.map((h) => h.constructionYear).filter((y) => y && y > 0); const areas = houses.map((h) => h.livingArea).filter((a) => a && a > 0); const lots = houses.map((h) => h.totalArea).filter((l) => l && l > 0); // Update min/max values, ensuring they're reasonable this.minPrice = Math.min(...prices); this.maxPrice = Math.max(...prices); this.minYear = Math.min(...years); this.maxYear = Math.max(...years); this.minArea = Math.min(...areas); this.maxArea = Math.max(...areas); this.minLot = Math.min(...lots); this.maxLot = Math.max(...lots); } } 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 {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([ DataProvider.getDistricts(), DataProvider.getHouses(), DataProvider.getTrainStations(), DataProvider.getTrainTracks(), DataProvider.getFeaturesFromCouch("Seutukartta_meren_rantaviiva"), DataProvider.getFeaturesFromCouch("Seutukartta_liikenne_paatiet"), DataProvider.getStatisticalAreas(), await DataProvider.getFeaturesFromCouch("RaideJokeri_pysakit"), //DataProvider.getJokerTramTracks(), DataProvider.getLightRailStops(), DataProvider.getLightRailTracks(), ]); const collection = new Collection( districts, houses, trainStations, trainTracks, coastLine, mainRoads, statisticalAreas, jokerTramStops, //jokerTramTracks, lightRailStops, lightRailTracks, ); collection.#precomputeHouseData(); return collection; } /** * Precompute statistical area references and transit distances for all houses */ #precomputeHouseData() { for (const house of this.houses) { // Find statistical area for this house house.statisticalArea = this.#findStatisticalArea(house.coordinates); // Calculate transit distances house.geospatial.distanceTrain = this.#calculateMinDistanceToStations( house.coordinates, this.trainStations, ); house.geospatial.distanceLightRail = this.#calculateMinDistanceToStations( house.coordinates, this.lightRailStops, ); } } /** * Find statistical area that contains the house coordinates * @param {Point} coordinates * @returns {StatisticalArea|null} */ #findStatisticalArea(coordinates) { for (const area of this.statisticalAreas) { if (area.contains(coordinates)) { return area; } } return null; } /** * Calculate minimum distance to any station in the array * @param {Point} point * @param {TrainStation[]} stations * @returns {number} distance in meters */ #calculateMinDistanceToStations(point, stations) { if (stations.length === 0) return Infinity; let minDistance = Infinity; for (const station of stations) { const distance = Point.distance(point, station.polygon.centroid()); if (distance < minDistance) { minDistance = distance; } } return minDistance; } } export class DataProvider { static couchBaseUrl = "https://couch.tammi.cc"; static wfsDbName = "helsinki_wfs"; static housesDbName = "asunnot"; /** * Fetch all features for a layer as a GeoJSON FeatureCollection * @param {string} layerName * @returns {Promise} */ 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`, DataProvider.couchBaseUrl, ); const params = new URLSearchParams({ // biome-ignore lint/style/useNamingConvention: url search params include_docs: "true", key: JSON.stringify(layerName), }); const response = await fetch(`${viewUrl}?${params}`, { headers: new Headers({ accept: "application/json" }), method: "GET", mode: "cors", }); if (!response.ok) { throw new Error(`HTTP ${response.status}`); } const result = await response.json(); const features = result.rows .map((row) => { if (row.doc.geometry && "type" in row.doc.geometry) { const geom = Geometry.fromGeoJSON(row.doc.geometry); return new Feature(geom, Geometry.toEnum(geom), row.doc.properties, row.doc._id); } else { return null; } }) .filter((x) => x !== null); return new FeatureCollection(features); } /** @returns {Promise} */ static async getDistricts() { return District.fromFeatureCollection( await DataProvider.getFeaturesFromCouch("Piirijako_peruspiiri"), ); } /** @returns {Promise} */ static async getTrainStations() { return TrainStation.fromFeatureCollection( await DataProvider.getFeaturesFromCouch("Seutukartta_liikenne_juna_asema"), ); } /** @returns {Promise} */ static async getTrainTracks() { return TrainTracks.fromFeatureCollection( await DataProvider.getFeaturesFromCouch("Seutukartta_liikenne_juna_rata"), ); } /** @returns {Promise} */ static async getLightRailStops() { return TrainStation.fromFeatureCollection( await DataProvider.getFeaturesFromCouch("Seutukartta_liikenne_metroasemat"), ); } /** @returns {Promise} */ static async getLightRailTracks() { return TrainTracks.fromFeatureCollection( await DataProvider.getFeaturesFromCouch("Seutukartta_liikenne_metro_rata"), ); } /** @returns {Promise} */ static async getJokerTramTracks() { return await DataProvider.getFeaturesFromCouch("RaideJokeri_ratalinja"); } /** @returns {Promise} */ static async getStatisticalAreas() { return StatisticalArea.fromFeatureCollection( await DataProvider.getFeaturesFromCouch("Aluesarjat_avainluvut_2024"), ); } /** @returns {Promise} */ static async getHouses() { try { const response = await fetch( new URL( `/${DataProvider.housesDbName}/_all_docs?include_docs=true`, DataProvider.couchBaseUrl, ), { headers: new Headers({ accept: "application/json" }), mode: "cors", }, ); if (!response.ok) { console.error(`API response not OK: ${response.status}`); return []; } 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`); return housesData.map(House.fromJson); } catch (error) { console.error("Failed to load houses:", error); return []; } } } export class Weights { constructor() { this.price = 0.5; this.distanceMarket = 0.5; this.distanceSchool = 0.5; // Removed: crimeRate, safety, s2Students this.distanceRailway = 0.5; this.constructionYear = 0.5; this.livingArea = 0.5; // New weights for statistical area data this.foreignSpeakers = 0.5; this.unemploymentRate = 0.5; this.averageIncome = 0.5; this.higherEducation = 0.5; // New weights for transit distances this.distanceTrain = 0.5; this.distanceLightRail = 0.5; this.distanceTram = 0.5; } } export class ScoringEngine { /** * Hedonic Pricing Model * PV = exp( β0 + Σ βi * Xi ) * Score = 100 * PV / ListingPrice * @param {House} house * @param {Weights} weights */ static calculate(house, weights) { // ---------------------------- // 1. Collect feature values Xi // ---------------------------- const X = {}; // Normalized price per sqm (lower is better → negative effect) X.price = house.pricePerSqm > 0 ? -Math.log(house.pricePerSqm) : 0; // Construction year (newer better) X.constructionYear = house.constructionYear > 0 ? (house.constructionYear - 1900) / (new Date().getFullYear() - 1900) : 0; // Living area (log-size effect typical in hedonic models) X.livingArea = house.livingArea > 0 ? Math.log(house.livingArea) : 0; // Distances (closer better → negative sign) X.distanceMarket = house.geospatial?.marketDistance > 0 ? -Math.log(1 + house.geospatial.marketDistance) : 0; X.distanceSchool = house.geospatial?.schoolDistance > 0 ? -Math.log(1 + house.geospatial.schoolDistance) : 0; X.distanceTrain = house.geospatial?.distanceTrain > 0 ? -Math.log(1 + house.geospatial.distanceTrain) : 0; X.distanceLightRail = house.geospatial?.distanceLightRail > 0 ? -Math.log(1 + house.geospatial.distanceLightRail) : 0; X.distanceTram = house.geospatial?.distanceTram > 0 ? -Math.log(1 + house.geospatial.distanceTram) : 0; // Statistical area variables if (house.statisticalArea) { const sa = house.statisticalArea; X.foreignSpeakers = sa.foreignSpeakers != null ? -sa.foreignSpeakers : 0; X.unemploymentRate = sa.unemploymentRate != null ? -(sa.unemploymentRate / 100) : 0; X.averageIncome = sa.averageIncome != null ? Math.log(sa.averageIncome) : 0; X.higherEducation = sa.higherEducation != null ? sa.higherEducation : 0; } else { X.foreignSpeakers = 0; X.unemploymentRate = 0; X.averageIncome = 0; X.higherEducation = 0; } // ---------------------------- // 2. Compute hedonic log-value // ---------------------------- let logPV = 0; for (const [key, value] of Object.entries(X)) { const β = weights[key]; if (typeof β === "number" && β !== 0) { logPV += β * value; } } // ---------------------------- // 3. Convert to perceived value // ---------------------------- const Pv = Math.exp(logPV); // ---------------------------- // 4. Compare with listing price // ---------------------------- if (!house.price || house.price <= 0) return 0; const desirability = (Pv / house.price) * 100; return Math.max(0, Math.min(100, desirability)); } }