From b03ee7032b2ea2d4d22ab7ec1346b7c9331cfc17 Mon Sep 17 00:00:00 2001 From: Petri Hienonen Date: Wed, 29 Oct 2025 15:18:30 +0200 Subject: Initial commit --- app/models.js | 622 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 622 insertions(+) create mode 100644 app/models.js (limited to 'app/models.js') diff --git a/app/models.js b/app/models.js new file mode 100644 index 0000000..24dd5a8 --- /dev/null +++ b/app/models.js @@ -0,0 +1,622 @@ +import { Collection, Feature, 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 + */ + +/** + * API response structure + * @typedef {Object} ApiResponse + * @property {number} total_rows + * @property {number} offset + * @property {Array<{doc: HouseJson}>} rows + */ + +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} [crimeRate] + * @param {number} [safetyIndex] + * @param {number} [s2StudentRatio] + * @param {number} [railwayDistance] + */ + constructor( + marketDistance = 0, + schoolDistance = 0, + crimeRate = 0, + safetyIndex = 0, + s2StudentRatio = 0, + railwayDistance = 0, + ) { + this.marketDistance = marketDistance; + this.schoolDistance = schoolDistance; + this.crimeRate = crimeRate; + this.safetyIndex = safetyIndex; + this.s2StudentRatio = s2StudentRatio; + this.railwayDistance = railwayDistance; + } + + /** @param {GeospatialJson} data @returns {Geospatial} */ + static fromJson(data) { + return new Geospatial( + data.marketDistance ?? 0, + data.schoolDistance ?? 0, + data.crimeRate ?? 0, + data.safetyIndex ?? 0, + data.s2StudentRatio ?? 0, + data.railwayDistance ?? 0, + ); + } +} + +/** + * Represents a geographic district with name and polygon + */ +export class District { + /** + * @param {string} name + * @param {Polygon} polygon + */ + constructor(name, polygon) { + this.name = name; + this.polygon = polygon; + } + + /** + * @param {Feature} feature + * @returns {District} + */ + static fromFeature(feature) { + const name = feature.properties?.nimi_fi; + const geometry = feature.geometry; + + if (name === undefined || !(geometry instanceof Polygon)) { + throw new Error("Invalid district feature data"); + } + + return new District(name, geometry); + } + + /** + * Convert Collection to District[] + * @param {Collection} collection + * @returns {District[]} + */ + static fromCollection(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) { + const geometry = feature.geometry; + if (!(geometry instanceof LineString)) { + throw new Error("Invalid train tracks feature data"); + } + return new TrainTracks(geometry); + } + + /** + * Convert Collection to TrainTracks[] + * @param {Collection} collection + * @returns {TrainTracks[]} + */ + static fromCollection(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) { + const geometry = feature.geometry; + if (!(geometry instanceof Polygon)) { + console.warn(`Train station feature ${JSON.stringify(feature)} is not a Polygon`); + return null; + } + return new TrainStation(geometry); + } + + /** + * Convert Collection to TrainStation[] + * @param {Collection} collection + * @returns {TrainStation[]} + */ + static fromCollection(collection) { + return collection.features.map(TrainStation.fromFeature).filter((x) => x !== null); + } +} + +export class House { + /** + * @param {string} id + * @param {string} address + * @param {string} district + * @param {Point} coordinates - Use Point geometry instead of GeoPoint + * @param {string} [postalCode] + * @param {number} price + * @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} firstSeen + * @param {Date} lastSeen + * @param {Date} lastUpdated + * @param {Date|null} [disappeared] + * @param {Scores} scores + * @param {string[]} images + * @param {Geospatial} [geospatial] + */ + constructor( + id, + address, + district, + coordinates, + postalCode = "", + price, + buildingType, + constructionYear = 0, + rooms = 0, + livingArea = 0, + totalArea = 0, + floorCount = 1, + condition = "", + description = "", + priceHistory = [], + firstSeen, + lastSeen, + lastUpdated, + disappeared = null, + scores = new Scores(0), + images = [], + geospatial = new Geospatial(), + ) { + this.id = id; + this.address = address; + this.district = district; + this.coordinates = coordinates; + this.postalCode = postalCode; + this.price = price; + 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; + } + + /** @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, + rawLocation.zipCode || "", + parsePrice(rawData.price), + 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(), + ); + } + + /** + * 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 + 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; + } + + /** + * Convert to GeoJSON Feature for export/display + * @returns {Feature} + */ + toFeature() { + return new Feature( + this.coordinates, + { + address: this.address, + buildingType: this.buildingType, + constructionYear: this.constructionYear, + district: this.district, + id: this.id, + livingArea: this.livingArea, + price: this.price, + rooms: this.rooms, + score: this.scores.current, + }, + this.id, + ); + } +} + +export class Weights { + constructor() { + this.price = 0.5; + this.distanceMarket = 0.5; + this.distanceSchool = 0.5; + this.crimeRate = 0.5; + this.safety = 0.5; + this.s2Students = 0.5; + this.distanceRailway = 0.5; + this.constructionYear = 0.5; + } +} + +export class Filters { + constructor() { + this.minPrice = 0; + this.maxPrice = Number.POSITIVE_INFINITY; + this.minYear = 0; + this.minArea = 0; + /** @type {string[]} */ + this.districts = []; + } +} + +export class DataProvider { + /** @returns {Promise} */ + static async getCoastline() { + return await DataProvider.getCollection("data/rantaviiva.json"); + } + + /** @returns {Promise} */ + static async getMainRoads() { + return await DataProvider.getCollection("data/paatiet.json"); + } + + /** @returns {Promise} */ + static async getDistricts() { + const collection = await DataProvider.getCollection("data/districts.json"); + return District.fromCollection(collection); + } + + /** @returns {Promise} */ + static async getTrainStations() { + const collection = await DataProvider.getCollection("data/juna_asemat.json"); + return TrainStation.fromCollection(collection); + } + + /** @returns {Promise} */ + static async getTrainTracks() { + const collection = await DataProvider.getCollection("data/juna_radat.json"); + return TrainTracks.fromCollection(collection); + } + + /** + * Load any GeoJSON file as Feature Collection + * @param {string} url + * @returns {Promise} + */ + static async getCollection(url) { + const response = await fetch(url); + if (!response.ok) throw new Error(`Failed to load GeoJSON from ${url}: ${response.status}`); + const geojson = await response.json(); + return Collection.fromGeoJSON(geojson); + } + + /** @returns {Promise} */ + static async getHouses() { + try { + const response = await fetch( + new URL("/asunnot/_all_docs?include_docs=true", "https://couch.tammi.cc"), + { + headers: new Headers({ accept: "application/json" }), + mode: "cors", + }, + ); + + if (!response.ok) { + console.error(`API response not OK: ${response.status}`); + return []; + } + + const data = /** @type {ApiResponse} */ (await response.json()); + + // Extract the doc objects from each row and filter out any null/undefined + 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 []; + } + } + + /** + * Convert houses to Feature Collection for export + * @param {House[]} houses + * @returns {Collection} + */ + static housesToCollection(houses) { + const features = houses.map((house) => house.toFeature()); + return new Collection(features); + } +} + +export class ScoringEngine { + /** + * @param {House} house + * @param {Weights} weights + * @returns {number} 0–100 + */ + static calculate(house, weights) { + let score = 0; + let totalWeight = 0; + const g = house.geospatial; + + if (weights.price > 0) { + const priceScore = Math.max(0, 1 - house.price / 1_000_000); + score += priceScore * weights.price; + totalWeight += weights.price; + } + + if (weights.constructionYear > 0 && house.constructionYear > 0) { + const currentYear = new Date().getFullYear(); + const yearScore = (house.constructionYear - 1950) / (currentYear - 1950); + score += yearScore * weights.constructionYear; + totalWeight += weights.constructionYear; + } + + if (weights.constructionYear > 0) { + const areaScore = Math.min(1, house.livingArea / 200); + score += areaScore * weights.constructionYear; + totalWeight += weights.constructionYear; + } + + if (weights.distanceMarket > 0 && g.marketDistance > 0) { + const marketScore = Math.max(0, 1 - g.marketDistance / 5000); + score += marketScore * weights.distanceMarket; + totalWeight += weights.distanceMarket; + } + + if (weights.distanceSchool > 0 && g.schoolDistance > 0) { + const schoolScore = Math.max(0, 1 - g.schoolDistance / 5000); + score += schoolScore * weights.distanceSchool; + totalWeight += weights.distanceSchool; + } + + if (weights.crimeRate > 0 && g.crimeRate > 0) { + const crimeScore = Math.max(0, 1 - g.crimeRate / 10); + score += crimeScore * weights.crimeRate; + totalWeight += weights.crimeRate; + } + + if (weights.safety > 0 && g.safetyIndex > 0) { + const safetyScore = g.safetyIndex / 10; + score += safetyScore * weights.safety; + totalWeight += weights.safety; + } + + if (weights.s2Students > 0 && g.s2StudentRatio > 0) { + const studentScore = 1 - Math.abs(g.s2StudentRatio - 0.5); + score += studentScore * weights.s2Students; + totalWeight += weights.s2Students; + } + + if (weights.distanceRailway > 0 && g.railwayDistance > 0) { + const dist = g.railwayDistance; + const railwayScore = dist > 500 ? Math.min(1, (2000 - dist) / 1500) : Math.min(1, dist / 500); + score += railwayScore * weights.distanceRailway; + totalWeight += weights.distanceRailway; + } + + return totalWeight > 0 ? (score / totalWeight) * 100 : 0; + } +} -- cgit v1.2.3-70-g09d2