aboutsummaryrefslogtreecommitdiffstats
path: root/app/models.js
diff options
context:
space:
mode:
authorPetri Hienonen <petri.hienonen@gmail.com>2025-10-29 15:18:30 +0200
committerPetri Hienonen <petri.hienonen@gmail.com>2025-11-03 10:54:48 +0200
commitb03ee7032b2ea2d4d22ab7ec1346b7c9331cfc17 (patch)
treeefc0ce6823ab8611d9c6a0bf27ecdbd124638b73 /app/models.js
downloadhousing-b03ee7032b2ea2d4d22ab7ec1346b7c9331cfc17.tar.zst
Initial commit
Diffstat (limited to 'app/models.js')
-rw-r--r--app/models.js622
1 files changed, 622 insertions, 0 deletions
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<Collection>} */
+ static async getCoastline() {
+ return await DataProvider.getCollection("data/rantaviiva.json");
+ }
+
+ /** @returns {Promise<Collection>} */
+ static async getMainRoads() {
+ return await DataProvider.getCollection("data/paatiet.json");
+ }
+
+ /** @returns {Promise<District[]>} */
+ static async getDistricts() {
+ const collection = await DataProvider.getCollection("data/districts.json");
+ return District.fromCollection(collection);
+ }
+
+ /** @returns {Promise<TrainStation[]>} */
+ static async getTrainStations() {
+ const collection = await DataProvider.getCollection("data/juna_asemat.json");
+ return TrainStation.fromCollection(collection);
+ }
+
+ /** @returns {Promise<TrainTracks[]>} */
+ 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<Collection>}
+ */
+ 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<House[]>} */
+ 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;
+ }
+}