bae705aa97
- Add NFC ePassport roadmap (ICAO 9303, eIDAS) - Add TensorFlow.js edge face detection (BlazeFace) - Add structured audit logger (GDPR-compliant) - Risk scoring support Part of KYC Apple Native UX v1.1.0
564 lines
16 KiB
JavaScript
564 lines
16 KiB
JavaScript
/**
|
|
* QUIXZOOM Area Knowledge Graph
|
|
*
|
|
* Hierarkisk struktur:
|
|
* Area (Bangkok)
|
|
* └── District (Sukhumvit)
|
|
* └── Road (Sukhumvit Road)
|
|
* └── Segment (Soi 22)
|
|
* └── Object (Streetlight)
|
|
* └── Observation
|
|
* └── Confidence
|
|
* └── Last Seen
|
|
* └── Needs Verification
|
|
* └── Knowledge Gap
|
|
*
|
|
* Bangkok blir bara den första regionen.
|
|
* Samma modell fungerar i Stockholm, London, Tokyo.
|
|
*/
|
|
|
|
class AreaKnowledgeGraph {
|
|
constructor() {
|
|
this.areas = new Map(); // areaId -> Area
|
|
this.objects = new Map(); // objectId -> Object (global index)
|
|
this.observations = new Map(); // observationId -> Observation
|
|
}
|
|
|
|
// ============================================================
|
|
// AREA
|
|
// ============================================================
|
|
|
|
createArea(config) {
|
|
const area = {
|
|
id: config.id || this.generateId(),
|
|
name: config.name,
|
|
bounds: config.bounds,
|
|
districts: new Map(),
|
|
statistics: {
|
|
totalObservations: 0,
|
|
totalObjects: 0,
|
|
coverage: 0,
|
|
lastUpdated: Date.now(),
|
|
},
|
|
};
|
|
|
|
this.areas.set(area.id, area);
|
|
return area;
|
|
}
|
|
|
|
getArea(areaId) {
|
|
return this.areas.get(areaId);
|
|
}
|
|
|
|
// ============================================================
|
|
// DISTRICT
|
|
// ============================================================
|
|
|
|
createDistrict(areaId, config) {
|
|
const area = this.areas.get(areaId);
|
|
if (!area) throw new Error(`Area not found: ${areaId}`);
|
|
|
|
const district = {
|
|
id: config.id || this.generateId(),
|
|
name: config.name,
|
|
areaId,
|
|
bounds: config.bounds,
|
|
roads: new Map(),
|
|
statistics: {
|
|
totalObservations: 0,
|
|
totalObjects: 0,
|
|
coverage: 0,
|
|
},
|
|
};
|
|
|
|
area.districts.set(district.id, district);
|
|
return district;
|
|
}
|
|
|
|
getDistrict(areaId, districtId) {
|
|
const area = this.areas.get(areaId);
|
|
return area?.districts.get(districtId);
|
|
}
|
|
|
|
// ============================================================
|
|
// ROAD
|
|
// ============================================================
|
|
|
|
createRoad(areaId, districtId, config) {
|
|
const district = this.getDistrict(areaId, districtId);
|
|
if (!district) throw new Error(`District not found: ${districtId}`);
|
|
|
|
const road = {
|
|
id: config.id || this.generateId(),
|
|
name: config.name,
|
|
areaId,
|
|
districtId,
|
|
type: config.type || 'street', // street, avenue, highway, alley
|
|
segments: new Map(),
|
|
statistics: {
|
|
totalObservations: 0,
|
|
totalObjects: 0,
|
|
length: config.length || 0,
|
|
},
|
|
};
|
|
|
|
district.roads.set(road.id, road);
|
|
return road;
|
|
}
|
|
|
|
// ============================================================
|
|
// SEGMENT
|
|
// ============================================================
|
|
|
|
createSegment(areaId, districtId, roadId, config) {
|
|
const road = this.getRoad(areaId, districtId, roadId);
|
|
if (!road) throw new Error(`Road not found: ${roadId}`);
|
|
|
|
const segment = {
|
|
id: config.id || this.generateId(),
|
|
name: config.name,
|
|
areaId,
|
|
districtId,
|
|
roadId,
|
|
bounds: config.bounds,
|
|
objects: new Map(),
|
|
observations: [],
|
|
statistics: {
|
|
totalObservations: 0,
|
|
totalObjects: 0,
|
|
coverage: 0,
|
|
quality: 0,
|
|
},
|
|
};
|
|
|
|
road.segments.set(segment.id, segment);
|
|
return segment;
|
|
}
|
|
|
|
getSegment(areaId, districtId, roadId, segmentId) {
|
|
const road = this.getRoad(areaId, districtId, roadId);
|
|
return road?.segments.get(segmentId);
|
|
}
|
|
|
|
// ============================================================
|
|
// OBJECT
|
|
// ============================================================
|
|
|
|
createObject(config) {
|
|
const object = {
|
|
id: config.id || this.generateId(),
|
|
type: config.type, // streetlight, pothole, crosswalk, etc.
|
|
class: config.class, // infrastructure, vehicle, vegetation, etc.
|
|
areaId: config.areaId,
|
|
districtId: config.districtId,
|
|
roadId: config.roadId,
|
|
segmentId: config.segmentId,
|
|
location: config.location, // { lat, lng }
|
|
firstSeen: Date.now(),
|
|
lastSeen: Date.now(),
|
|
observations: [],
|
|
confidence: config.confidence || 0,
|
|
verificationStatus: config.verificationStatus || 'unverified',
|
|
needsVerification: config.needsVerification || false,
|
|
attributes: config.attributes || {},
|
|
};
|
|
|
|
this.objects.set(object.id, object);
|
|
|
|
// Lägg till i segment
|
|
if (config.segmentId) {
|
|
const segment = this.getSegment(config.areaId, config.districtId, config.roadId, config.segmentId);
|
|
if (segment) {
|
|
segment.objects.set(object.id, object);
|
|
}
|
|
}
|
|
|
|
return object;
|
|
}
|
|
|
|
getObject(objectId) {
|
|
return this.objects.get(objectId);
|
|
}
|
|
|
|
// ============================================================
|
|
// OBSERVATION
|
|
// ============================================================
|
|
|
|
addObservation(config) {
|
|
const observation = {
|
|
id: config.id || this.generateId(),
|
|
objectId: config.objectId,
|
|
sourceType: config.sourceType, // quixzoom, youtube_cc, etc.
|
|
sourceId: config.sourceId,
|
|
timestamp: config.timestamp || Date.now(),
|
|
location: config.location,
|
|
quality: config.quality || {},
|
|
aiAnalysis: config.aiAnalysis || {},
|
|
reliability: config.reliability || 0,
|
|
verificationStatus: config.verificationStatus || 'unverified',
|
|
mediaUrl: config.mediaUrl,
|
|
metadata: config.metadata || {},
|
|
};
|
|
|
|
this.observations.set(observation.id, observation);
|
|
|
|
// Lägg till i objekt
|
|
const object = this.objects.get(config.objectId);
|
|
if (object) {
|
|
object.observations.push(observation.id);
|
|
object.lastSeen = observation.timestamp;
|
|
|
|
// Uppdatera konfidens
|
|
const allObservations = object.observations.map(id => this.observations.get(id));
|
|
const avgConfidence = allObservations.reduce((sum, obs) => sum + (obs?.reliability || 0), 0) / allObservations.length;
|
|
object.confidence = avgConfidence;
|
|
|
|
// Markera som verifierad om tillräckligt många observationer
|
|
if (allObservations.length >= 3 && avgConfidence > 0.7) {
|
|
object.verificationStatus = 'verified';
|
|
object.needsVerification = false;
|
|
}
|
|
}
|
|
|
|
// Uppdatera statistik
|
|
this.updateStatistics(config.areaId);
|
|
|
|
return observation;
|
|
}
|
|
|
|
// ============================================================
|
|
// KNOWLEDGE GAPS
|
|
// ============================================================
|
|
|
|
identifyKnowledgeGaps(areaId) {
|
|
const area = this.areas.get(areaId);
|
|
if (!area) return [];
|
|
|
|
const gaps = [];
|
|
|
|
// 1. Spatiala gap — områden utan observationer
|
|
const spatialGaps = this.findSpatialGaps(area);
|
|
gaps.push(...spatialGaps);
|
|
|
|
// 2. Temporala gap — tider utan data
|
|
const temporalGaps = this.findTemporalGaps(area);
|
|
gaps.push(...temporalGaps);
|
|
|
|
// 3. Objekt-typer med få exempel
|
|
const categoricalGaps = this.findCategoricalGaps(area);
|
|
gaps.push(...categoricalGaps);
|
|
|
|
// 4. Områden med låg konfidens
|
|
const confidenceGaps = this.findConfidenceGaps(area);
|
|
gaps.push(...confidenceGaps);
|
|
|
|
// 5. Objekt som behöver verifiering
|
|
const verificationGaps = this.findVerificationGaps(area);
|
|
gaps.push(...verificationGaps);
|
|
|
|
return gaps.sort((a, b) => b.priority - a.priority);
|
|
}
|
|
|
|
findSpatialGaps(area) {
|
|
const gaps = [];
|
|
const gridSize = 0.001; // ~100m
|
|
|
|
for (const district of area.districts.values()) {
|
|
for (const road of district.roads.values()) {
|
|
for (const segment of road.segments.values()) {
|
|
if (segment.observations.length < 5) {
|
|
gaps.push({
|
|
type: 'spatial',
|
|
subtype: 'insufficient_coverage',
|
|
location: segment.bounds,
|
|
description: `${segment.name} har få observationer`,
|
|
priority: 0.7,
|
|
suggestedAction: 'Dokumentera segmentet mer noggrant',
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return gaps;
|
|
}
|
|
|
|
findTemporalGaps(area) {
|
|
const gaps = [];
|
|
const hourCounts = new Array(24).fill(0);
|
|
|
|
for (const observation of this.observations.values()) {
|
|
if (observation.location && this.isInArea(observation.location, area)) {
|
|
const hour = new Date(observation.timestamp).getHours();
|
|
hourCounts[hour]++;
|
|
}
|
|
}
|
|
|
|
const avgCount = this.observations.size / 24;
|
|
|
|
for (let hour = 0; hour < 24; hour++) {
|
|
if (hourCounts[hour] < avgCount * 0.3) {
|
|
gaps.push({
|
|
type: 'temporal',
|
|
subtype: 'missing_time_period',
|
|
timeOfDay: hour,
|
|
description: `Få observationer kl ${hour}:00-${hour + 1}:00`,
|
|
priority: 0.5,
|
|
suggestedAction: `Dokumentera området kl ${hour}:00`,
|
|
});
|
|
}
|
|
}
|
|
|
|
return gaps;
|
|
}
|
|
|
|
findCategoricalGaps(area) {
|
|
const gaps = [];
|
|
const objectTypes = new Map();
|
|
|
|
for (const object of this.objects.values()) {
|
|
if (object.areaId === area.id) {
|
|
const count = objectTypes.get(object.type) || 0;
|
|
objectTypes.set(object.type, count + 1);
|
|
}
|
|
}
|
|
|
|
const avgCount = this.objects.size / objectTypes.size;
|
|
|
|
for (const [type, count] of objectTypes) {
|
|
if (count < avgCount * 0.2) {
|
|
gaps.push({
|
|
type: 'categorical',
|
|
subtype: 'rare_object_type',
|
|
objectType: type,
|
|
description: `Få exempel av ${type} (${count} observationer)`,
|
|
priority: 0.6,
|
|
suggestedAction: `Dokumentera fler ${type}`,
|
|
});
|
|
}
|
|
}
|
|
|
|
return gaps;
|
|
}
|
|
|
|
findConfidenceGaps(area) {
|
|
const gaps = [];
|
|
|
|
for (const object of this.objects.values()) {
|
|
if (object.areaId === area.id && object.confidence < 0.5) {
|
|
gaps.push({
|
|
type: 'confidence',
|
|
subtype: 'low_confidence_object',
|
|
objectId: object.id,
|
|
description: `${object.type} har låg konfidens (${(object.confidence * 100).toFixed(0)}%)`,
|
|
priority: 0.8,
|
|
suggestedAction: 'Verifiera objektet från annan vinkel',
|
|
});
|
|
}
|
|
}
|
|
|
|
return gaps;
|
|
}
|
|
|
|
findVerificationGaps(area) {
|
|
const gaps = [];
|
|
|
|
for (const object of this.objects.values()) {
|
|
if (object.areaId === area.id && object.needsVerification) {
|
|
gaps.push({
|
|
type: 'verification',
|
|
subtype: 'needs_verification',
|
|
objectId: object.id,
|
|
description: `${object.type} behöver verifiering`,
|
|
priority: 0.9,
|
|
suggestedAction: 'Dokumentera samma objekt från annan vinkel eller tid',
|
|
});
|
|
}
|
|
}
|
|
|
|
return gaps;
|
|
}
|
|
|
|
// ============================================================
|
|
// STATISTICS
|
|
// ============================================================
|
|
|
|
updateStatistics(areaId) {
|
|
const area = this.areas.get(areaId);
|
|
if (!area) return;
|
|
|
|
let totalObservations = 0;
|
|
let totalObjects = 0;
|
|
|
|
for (const district of area.districts.values()) {
|
|
for (const road of district.roads.values()) {
|
|
for (const segment of road.segments.values()) {
|
|
totalObservations += segment.observations.length;
|
|
totalObjects += segment.objects.size;
|
|
}
|
|
}
|
|
}
|
|
|
|
area.statistics = {
|
|
totalObservations,
|
|
totalObjects,
|
|
coverage: this.calculateCoverage(area),
|
|
lastUpdated: Date.now(),
|
|
};
|
|
}
|
|
|
|
calculateCoverage(area) {
|
|
const uniqueLocations = new Set();
|
|
|
|
for (const observation of this.observations.values()) {
|
|
if (observation.location && this.isInArea(observation.location, area)) {
|
|
const key = `${observation.location.lat.toFixed(4)},${observation.location.lng.toFixed(4)}`;
|
|
uniqueLocations.add(key);
|
|
}
|
|
}
|
|
|
|
// Förenklad: anta att 1000 unika platser är 100% täckning
|
|
return Math.min(1.0, uniqueLocations.size / 1000);
|
|
}
|
|
|
|
isInArea(location, area) {
|
|
const bounds = area.bounds;
|
|
return location.lat >= bounds.minLat &&
|
|
location.lat <= bounds.maxLat &&
|
|
location.lng >= bounds.minLng &&
|
|
location.lng <= bounds.maxLng;
|
|
}
|
|
|
|
// ============================================================
|
|
// EXPORT
|
|
// ============================================================
|
|
|
|
exportToJSON(areaId) {
|
|
const area = this.areas.get(areaId);
|
|
if (!area) return null;
|
|
|
|
const districts = [];
|
|
for (const district of area.districts.values()) {
|
|
const roads = [];
|
|
for (const road of district.roads.values()) {
|
|
const segments = [];
|
|
for (const segment of road.segments.values()) {
|
|
const objects = [];
|
|
for (const object of segment.objects.values()) {
|
|
const observations = object.observations.map(id => this.observations.get(id));
|
|
objects.push({
|
|
...object,
|
|
observations,
|
|
});
|
|
}
|
|
segments.push({
|
|
...segment,
|
|
objects,
|
|
});
|
|
}
|
|
roads.push({
|
|
...road,
|
|
segments,
|
|
});
|
|
}
|
|
districts.push({
|
|
...district,
|
|
roads,
|
|
});
|
|
}
|
|
|
|
return {
|
|
...area,
|
|
districts,
|
|
};
|
|
}
|
|
|
|
// ============================================================
|
|
// HJÄLPMETODER
|
|
// ============================================================
|
|
|
|
generateId() {
|
|
return `id_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
|
}
|
|
|
|
getRoad(areaId, districtId, roadId) {
|
|
const district = this.getDistrict(areaId, districtId);
|
|
return district?.roads.get(roadId);
|
|
}
|
|
}
|
|
|
|
// Exportera
|
|
module.exports = AreaKnowledgeGraph;
|
|
|
|
// Demo
|
|
if (require.main === module) {
|
|
const graph = new AreaKnowledgeGraph();
|
|
|
|
console.log('=== AREA KNOWLEDGE GRAPH DEMO ===\n');
|
|
|
|
// Skapa Bangkok
|
|
const bangkok = graph.createArea({
|
|
name: 'Bangkok',
|
|
bounds: { minLat: 13.5, maxLat: 14.0, minLng: 100.3, maxLng: 100.9 },
|
|
});
|
|
console.log(`Created area: ${bangkok.name} (${bangkok.id})`);
|
|
|
|
// Skapa distrikt
|
|
const sukhumvit = graph.createDistrict(bangkok.id, {
|
|
name: 'Sukhumvit',
|
|
bounds: { minLat: 13.7, maxLat: 13.8, minLng: 100.5, maxLng: 100.6 },
|
|
});
|
|
console.log(`Created district: ${sukhumvit.name}`);
|
|
|
|
// Skapa väg
|
|
const sukhumvitRoad = graph.createRoad(bangkok.id, sukhumvit.id, {
|
|
name: 'Sukhumvit Road',
|
|
type: 'avenue',
|
|
length: 4000,
|
|
});
|
|
console.log(`Created road: ${sukhumvitRoad.name}`);
|
|
|
|
// Skapa segment
|
|
const soi22 = graph.createSegment(bangkok.id, sukhumvit.id, sukhumvitRoad.id, {
|
|
name: 'Soi 22',
|
|
bounds: { minLat: 13.72, maxLat: 13.73, minLng: 100.55, maxLng: 100.56 },
|
|
});
|
|
console.log(`Created segment: ${soi22.name}`);
|
|
|
|
// Skapa objekt
|
|
const streetlight = graph.createObject({
|
|
type: 'streetlight',
|
|
class: 'infrastructure',
|
|
areaId: bangkok.id,
|
|
districtId: sukhumvit.id,
|
|
roadId: sukhumvitRoad.id,
|
|
segmentId: soi22.id,
|
|
location: { lat: 13.725, lng: 100.555 },
|
|
confidence: 0.8,
|
|
attributes: { condition: 'broken', height: 5 },
|
|
});
|
|
console.log(`Created object: ${streetlight.type} (${streetlight.id})`);
|
|
|
|
// Lägg till observation
|
|
const observation = graph.addObservation({
|
|
objectId: streetlight.id,
|
|
sourceType: 'quixzoom',
|
|
location: { lat: 13.725, lng: 100.555 },
|
|
quality: { sharpness: 0.9, exposure: 0.8 },
|
|
aiAnalysis: { objectDetection: 0.92, condition: 'broken' },
|
|
reliability: 0.85,
|
|
});
|
|
console.log(`Added observation: ${observation.id}`);
|
|
|
|
// Identifiera kunskapsgap
|
|
const gaps = graph.identifyKnowledgeGaps(bangkok.id);
|
|
console.log(`\nKnowledge gaps found: ${gaps.length}`);
|
|
for (const gap of gaps) {
|
|
console.log(` - ${gap.type}: ${gap.description} (priority: ${gap.priority})`);
|
|
}
|
|
|
|
// Exportera
|
|
const exported = graph.exportToJSON(bangkok.id);
|
|
console.log(`\nExported area with ${exported.districts.length} districts`);
|
|
}
|