/** * QUIXZOOM Urban Knowledge Graph (UKG) * * Centralt datalager — den verkliga "hjärnan" i QUIXZOOM. * * Tre lager: * 1. Observation Layer — Rådata → observationer * 2. Evidence Layer — Bevis för varje objekt * 3. Temporal Layer — Förändring över tid * * Varje objekt i staden får: * - Globalt objekt-ID * - Geografisk position * - Typ * - Attribut * - Observationer * - Historik * - Evidens * - Verifieringsstatus * - Osäkerhet * - Relationer till andra objekt */ const crypto = require('crypto'); class UrbanKnowledgeGraph { constructor() { // Objekt-index this.objects = new Map(); // objectId -> UrbanObject // Spatialt index (för snabb geografisk sökning) this.spatialIndex = new Map(); // gridKey -> Set(objectIds) // Typ-index this.typeIndex = new Map(); // type -> Set(objectIds) // Observationer this.observations = new Map(); // observationId -> Observation // Evidens this.evidence = new Map(); // evidenceId -> Evidence // Temporal this.temporal = new Map(); // objectId -> [TemporalRecord] // Relationer this.relations = new Map(); // objectId -> [Relation] // Grid-storlek för spatialt index (m) this.gridSize = 10; } // ============================================================ // OBJEKT-HANTERING // ============================================================ createObject(config) { const objectId = config.id || this.generateObjectId(config); const obj = { id: objectId, type: config.type, // street_lamp, pothole, crosswalk, etc. subtype: config.subtype, // Geografi location: { lat: config.location.lat, lng: config.location.lng, accuracy: config.location.accuracy || 5, altitude: config.location.altitude, }, // Attribut (kan ändras över tid) attributes: config.attributes || {}, // Metadata createdAt: Date.now(), updatedAt: Date.now(), createdBy: config.createdBy, // Status status: 'active', // active, removed, modified verificationStatus: config.verificationStatus || 'unverified', // Osäkerhet confidence: config.confidence || 0, uncertainty: config.uncertainty || 1.0, // Referenser observationIds: [], evidenceIds: [], temporalIds: [], relationIds: [], }; this.objects.set(objectId, obj); // Uppdatera index this.addToSpatialIndex(objectId, obj.location); this.addToTypeIndex(objectId, obj.type); console.log(`[UKG] Object created: ${objectId} (${obj.type})`); return obj; } getObject(objectId) { return this.objects.get(objectId); } updateObject(objectId, updates) { const obj = this.objects.get(objectId); if (!obj) return null; // Spara gammalt tillstånd i temporal layer this.addTemporalRecord(objectId, { timestamp: Date.now(), attributes: { ...obj.attributes }, verificationStatus: obj.verificationStatus, confidence: obj.confidence, }); // Uppdatera objekt if (updates.attributes) { obj.attributes = { ...obj.attributes, ...updates.attributes }; } if (updates.location) { // Ta bort från gammalt spatialt index this.removeFromSpatialIndex(objectId, obj.location); // Uppdatera plats obj.location = { ...obj.location, ...updates.location }; // Lägg till i nytt spatialt index this.addToSpatialIndex(objectId, obj.location); } if (updates.verificationStatus) { obj.verificationStatus = updates.verificationStatus; } if (updates.confidence !== undefined) { obj.confidence = updates.confidence; } obj.updatedAt = Date.now(); console.log(`[UKG] Object updated: ${objectId}`); return obj; } // ============================================================ // OBSERVATION LAYER // ============================================================ addObservation(config) { const observationId = config.id || this.generateId('obs'); const observation = { id: observationId, timestamp: config.timestamp || Date.now(), // Källa source: { type: config.sourceType, // quixzoom, youtube_cc, inspector, municipality id: config.sourceId, operator: config.operator, }, // Media media: { type: config.mediaType, // video, image url: config.mediaUrl, frameNumber: config.frameNumber, timestamp: config.mediaTimestamp, }, // Vad som observerades observedObjects: config.observedObjects || [], // Plats location: config.location, // Kvalitet quality: config.quality || {}, // AI-analys aiAnalysis: config.aiAnalysis || {}, // Metadata metadata: config.metadata || {}, }; this.observations.set(observationId, observation); // Koppla till objekt for (const observed of observation.observedObjects) { if (observed.objectId) { const obj = this.objects.get(observed.objectId); if (obj) { obj.observationIds.push(observationId); } } } console.log(`[UKG] Observation added: ${observationId}`); return observation; } getObservation(observationId) { return this.observations.get(observationId); } // ============================================================ // EVIDENCE LAYER // ============================================================ addEvidence(config) { const evidenceId = config.id || this.generateId('ev'); const evidence = { id: evidenceId, objectId: config.objectId, observationId: config.observationId, // Bevis proof: { type: config.proofType, // image, video, lidar, manual_inspection url: config.proofUrl, frameNumber: config.frameNumber, timestamp: config.proofTimestamp, }, // Vem som lade till beviset source: { type: config.sourceType, // quixzoom, inspector, municipality, ai id: config.sourceId, operator: config.operator, }, // Kvalitet quality: config.quality || {}, reliability: config.reliability || 0, // Metadata timestamp: config.timestamp || Date.now(), metadata: config.metadata || {}, }; this.evidence.set(evidenceId, evidence); // Koppla till objekt const obj = this.objects.get(config.objectId); if (obj) { obj.evidenceIds.push(evidenceId); } console.log(`[UKG] Evidence added: ${evidenceId} for object ${config.objectId}`); return evidence; } getEvidenceForObject(objectId) { const obj = this.objects.get(objectId); if (!obj) return []; return obj.evidenceIds.map(id => this.evidence.get(id)).filter(Boolean); } // ============================================================ // TEMPORAL LAYER // ============================================================ addTemporalRecord(objectId, record) { const temporalId = this.generateId('tmp'); const temporalRecord = { id: temporalId, objectId, timestamp: record.timestamp || Date.now(), // Tillstånd vid denna tidpunkt state: { attributes: record.attributes, verificationStatus: record.verificationStatus, confidence: record.confidence, status: record.status, }, // Vad som ändrades changes: record.changes || [], // Orsak till ändring reason: record.reason, source: record.source, }; if (!this.temporal.has(objectId)) { this.temporal.set(objectId, []); } this.temporal.get(objectId).push(temporalRecord); // Koppla till objekt const obj = this.objects.get(objectId); if (obj) { obj.temporalIds.push(temporalId); } return temporalRecord; } getTemporalHistory(objectId) { return this.temporal.get(objectId) || []; } getObjectAtTime(objectId, timestamp) { const history = this.getTemporalHistory(objectId); if (history.length === 0) return null; // Hitta senaste record före timestamp let closest = null; for (const record of history) { if (record.timestamp <= timestamp) { closest = record; } } return closest; } // ============================================================ // RELATIONER // ============================================================ addRelation(config) { const relationId = this.generateId('rel'); const relation = { id: relationId, fromObjectId: config.fromObjectId, toObjectId: config.toObjectId, type: config.relationType, // connected_to, part_of, near, supports, etc. attributes: config.attributes || {}, confidence: config.confidence || 0, timestamp: Date.now(), }; if (!this.relations.has(config.fromObjectId)) { this.relations.set(config.fromObjectId, []); } this.relations.get(config.fromObjectId).push(relation); // Koppla till objekt const fromObj = this.objects.get(config.fromObjectId); if (fromObj) { fromObj.relationIds.push(relationId); } console.log(`[UKG] Relation added: ${relationId} (${config.fromObjectId} -> ${config.toObjectId})`); return relation; } getRelations(objectId) { return this.relations.get(objectId) || []; } // ============================================================ // SPATIAL SÖKNING // ============================================================ findNearbyObjects(location, radius = 50) { const nearby = new Set(); const gridKeys = this.getGridKeysInRadius(location, radius); for (const key of gridKeys) { const objectIds = this.spatialIndex.get(key); if (objectIds) { for (const objectId of objectIds) { const obj = this.objects.get(objectId); if (obj) { const distance = this.calculateDistance(location, obj.location); if (distance <= radius) { nearby.add(obj); } } } } } return Array.from(nearby); } findObjectsByType(type, area = null) { const objectIds = this.typeIndex.get(type); if (!objectIds) return []; const objects = Array.from(objectIds) .map(id => this.objects.get(id)) .filter(Boolean); if (area) { return objects.filter(obj => this.isInArea(obj.location, area)); } return objects; } // ============================================================ // MERGE-OBJEKT (hundra videor → ett objekt) // ============================================================ mergeObjects(targetId, sourceIds) { const target = this.objects.get(targetId); if (!target) return null; for (const sourceId of sourceIds) { const source = this.objects.get(sourceId); if (!source || source.id === targetId) continue; // Flytta observationer for (const obsId of source.observationIds) { if (!target.observationIds.includes(obsId)) { target.observationIds.push(obsId); } } // Flytta evidens for (const evId of source.evidenceIds) { if (!target.evidenceIds.includes(evId)) { target.evidenceIds.push(evId); } // Uppdatera evidens-referens const ev = this.evidence.get(evId); if (ev) { ev.objectId = targetId; } } // Flytta temporal for (const tmpId of source.temporalIds) { if (!target.temporalIds.includes(tmpId)) { target.temporalIds.push(tmpId); } } // Ta bort källa this.objects.delete(sourceId); this.removeFromSpatialIndex(sourceId, source.location); this.removeFromTypeIndex(sourceId, source.type); console.log(`[UKG] Merged ${sourceId} into ${targetId}`); } // Uppdatera konfidens target.confidence = Math.min(1.0, target.confidence + 0.1 * sourceIds.length); target.verificationStatus = 'verified'; return target; } // ============================================================ // STATISTIK OCH ANALYS // ============================================================ getStatistics() { return { totalObjects: this.objects.size, totalObservations: this.observations.size, totalEvidence: this.evidence.size, totalTemporalRecords: Array.from(this.temporal.values()).reduce((sum, arr) => sum + arr.length, 0), totalRelations: Array.from(this.relations.values()).reduce((sum, arr) => sum + arr.length, 0), byType: this.getTypeStatistics(), byVerification: this.getVerificationStatistics(), }; } getTypeStatistics() { const stats = new Map(); for (const obj of this.objects.values()) { const count = stats.get(obj.type) || 0; stats.set(obj.type, count + 1); } return Object.fromEntries(stats); } getVerificationStatistics() { const stats = { verified: 0, unverified: 0, disputed: 0 }; for (const obj of this.objects.values()) { stats[obj.verificationStatus] = (stats[obj.verificationStatus] || 0) + 1; } return stats; } // ============================================================ // EXPORT // ============================================================ exportObject(objectId) { const obj = this.objects.get(objectId); if (!obj) return null; return { ...obj, observations: obj.observationIds.map(id => this.observations.get(id)).filter(Boolean), evidence: obj.evidenceIds.map(id => this.evidence.get(id)).filter(Boolean), temporal: this.getTemporalHistory(objectId), relations: this.getRelations(objectId), }; } exportArea(area) { const objects = []; for (const obj of this.objects.values()) { if (this.isInArea(obj.location, area)) { objects.push(this.exportObject(obj.id)); } } return { area, statistics: this.getStatistics(), objects, }; } // ============================================================ // HJÄLPMETODER // ============================================================ generateObjectId(config) { // Deterministiskt ID baserat på plats och typ const lat = config.location.lat.toFixed(6); const lng = config.location.lng.toFixed(6); const type = config.type; const hash = crypto.createHash('md5') .update(`${type}_${lat}_${lng}`) .digest('hex') .substring(0, 12); return `${type}_${hash}`; } generateId(prefix) { return `${prefix}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; } getGridKey(location) { const x = Math.floor(location.lng * 1000 / this.gridSize); const y = Math.floor(location.lat * 1000 / this.gridSize); return `${x},${y}`; } getGridKeysInRadius(location, radius) { const keys = []; const radiusInGrid = Math.ceil(radius / this.gridSize); const centerX = Math.floor(location.lng * 1000 / this.gridSize); const centerY = Math.floor(location.lat * 1000 / this.gridSize); for (let dx = -radiusInGrid; dx <= radiusInGrid; dx++) { for (let dy = -radiusInGrid; dy <= radiusInGrid; dy++) { keys.push(`${centerX + dx},${centerY + dy}`); } } return keys; } addToSpatialIndex(objectId, location) { const key = this.getGridKey(location); if (!this.spatialIndex.has(key)) { this.spatialIndex.set(key, new Set()); } this.spatialIndex.get(key).add(objectId); } removeFromSpatialIndex(objectId, location) { const key = this.getGridKey(location); const set = this.spatialIndex.get(key); if (set) { set.delete(objectId); } } addToTypeIndex(objectId, type) { if (!this.typeIndex.has(type)) { this.typeIndex.set(type, new Set()); } this.typeIndex.get(type).add(objectId); } removeFromTypeIndex(objectId, type) { const set = this.typeIndex.get(type); if (set) { set.delete(objectId); } } calculateDistance(a, b) { const R = 6371e3; const φ1 = a.lat * Math.PI / 180; const φ2 = b.lat * Math.PI / 180; const Δφ = (b.lat - a.lat) * Math.PI / 180; const Δλ = (b.lng - a.lng) * Math.PI / 180; const x = Math.sin(Δφ/2) * Math.sin(Δφ/2) + Math.cos(φ1) * Math.cos(φ2) * Math.sin(Δλ/2) * Math.sin(Δλ/2); const c = 2 * Math.atan2(Math.sqrt(x), Math.sqrt(1-x)); return R * c; } isInArea(location, area) { return location.lat >= area.minLat && location.lat <= area.maxLat && location.lng >= area.minLng && location.lng <= area.maxLng; } } // Exportera module.exports = UrbanKnowledgeGraph; // Demo if (require.main === module) { const ukg = new UrbanKnowledgeGraph(); console.log('=== URBAN KNOWLEDGE GRAPH DEMO ===\n'); // Skapa objekt: Street Lamp #1293 const lamp = ukg.createObject({ type: 'street_lamp', location: { lat: 13.725, lng: 100.555, accuracy: 2 }, attributes: { material: 'steel', height: 7.8, paint: 'grey', rust: false, lean: 0, light: 'working', }, confidence: 0.94, createdBy: 'quixzoom_system', }); console.log(`Created object: ${lamp.id}`); console.log(`Attributes:`, lamp.attributes); console.log(); // Lägg till observation const obs1 = ukg.addObservation({ sourceType: 'quixzoom', sourceId: 'zoomer_001', mediaType: 'video', mediaUrl: 'https://r2.landvex.com/videos/bangkok_001.mp4', frameNumber: 342, observedObjects: [{ objectId: lamp.id, confidence: 0.94, bbox: [100, 200, 150, 300], }], location: { lat: 13.725, lng: 100.555 }, quality: { resolution: 1080, sharpness: 0.9 }, aiAnalysis: { objectDetection: 0.94, condition: 'good' }, }); console.log(`Added observation: ${obs1.id}`); console.log(); // Lägg till evidens från flera källor const ev1 = ukg.addEvidence({ objectId: lamp.id, observationId: obs1.id, proofType: 'video', proofUrl: 'https://r2.landvex.com/videos/bangkok_001.mp4#frame=342', frameNumber: 342, sourceType: 'quixzoom', sourceId: 'zoomer_001', reliability: 0.85, }); const ev2 = ukg.addEvidence({ objectId: lamp.id, proofType: 'video', proofUrl: 'https://r2.landvex.com/videos/bangkok_002.mp4#frame=91', frameNumber: 91, sourceType: 'quixzoom', sourceId: 'zoomer_002', reliability: 0.80, }); const ev3 = ukg.addEvidence({ objectId: lamp.id, proofType: 'manual_inspection', sourceType: 'inspector', sourceId: 'inspector_001', reliability: 0.95, }); console.log(`Added ${lamp.evidenceIds.length} evidence items`); console.log(); // Temporal: Förändring över tid console.log('=== TEMPORAL HISTORY ==='); ukg.addTemporalRecord(lamp.id, { timestamp: new Date('2026-06-01').getTime(), attributes: { ...lamp.attributes, rust: false, lean: 0, light: 'working' }, verificationStatus: 'verified', confidence: 0.95, reason: 'Initial observation', }); ukg.addTemporalRecord(lamp.id, { timestamp: new Date('2026-08-12').getTime(), attributes: { ...lamp.attributes, rust: false, lean: 2, light: 'working' }, verificationStatus: 'verified', confidence: 0.90, reason: 'Tilt detected', changes: ['lean: 0 -> 2'], }); ukg.addTemporalRecord(lamp.id, { timestamp: new Date('2026-09-20').getTime(), attributes: { ...lamp.attributes, rust: true, lean: 4, light: 'flickering' }, verificationStatus: 'verified', confidence: 0.85, reason: 'Corrosion increasing', changes: ['rust: false -> true', 'lean: 2 -> 4', 'light: working -> flickering'], }); ukg.addTemporalRecord(lamp.id, { timestamp: new Date('2026-10-11').getTime(), attributes: { ...lamp.attributes, rust: true, lean: 4, light: 'broken' }, verificationStatus: 'verified', confidence: 0.95, reason: 'Light broken', changes: ['light: flickering -> broken'], }); ukg.addTemporalRecord(lamp.id, { timestamp: new Date('2026-11-02').getTime(), attributes: { ...lamp.attributes, rust: false, lean: 0, light: 'working' }, verificationStatus: 'verified', confidence: 0.98, reason: 'Replaced', changes: ['status: repaired'], }); const history = ukg.getTemporalHistory(lamp.id); for (const record of history) { const date = new Date(record.timestamp).toISOString().split('T')[0]; console.log(` ${date}: ${record.reason}`); if (record.changes.length > 0) { console.log(` Changes: ${record.changes.join(', ')}`); } } console.log(); // Visa evidens console.log('=== EVIDENCE ==='); const evidence = ukg.getEvidenceForObject(lamp.id); for (const ev of evidence) { console.log(` ${ev.source.type}: ${ev.proofType} (reliability: ${ev.reliability})`); } console.log(); // Statistik console.log('=== STATISTICS ==='); const stats = ukg.getStatistics(); console.log(`Total objects: ${stats.totalObjects}`); console.log(`Total observations: ${stats.totalObservations}`); console.log(`Total evidence: ${stats.totalEvidence}`); console.log(`Total temporal records: ${stats.totalTemporalRecords}`); console.log(); // Exportera objekt console.log('=== EXPORTED OBJECT ==='); const exported = ukg.exportObject(lamp.id); console.log(`ID: ${exported.id}`); console.log(`Type: ${exported.type}`); console.log(`Observations: ${exported.observations.length}`); console.log(`Evidence: ${exported.evidence.length}`); console.log(`Temporal records: ${exported.temporal.length}`); }