/** * TraceScene — Frontend Application * * Single-page application for accident case management, * photo upload, AI analysis, and report viewing. */ const API_BASE = '/api'; // ── Mock Backend Interceptor ─────────────────────────────────────────── // This simulates the AI backend purely in the browser for the public UI demo const originalFetch = window.fetch; let mockNextId = 104; // Pre-populate some demo cases let mockCases = { 101: { case: { id: 101, case_number: "INV-2026-042", officer_name: "Sgt. Miller", location: "Hwy 101 N", incident_date: "2026-03-01", status: "complete", created_at: "2026-03-01T08:14:00Z", photo_count: 3 }, photos: [ { id: "img_demo_1", filename: "01a252ba0708_car_8.jpg", blob_url: "images/demo/01a252ba0708_car_8.jpg" }, { id: "img_demo_2", filename: "bb7313c8f601_car_10.jpg", blob_url: "images/demo/bb7313c8f601_car_10.jpg" }, { id: "img_demo_3", filename: "413495119937_accident_6.jpg", blob_url: "images/demo/413495119937_accident_6.jpg" } ], analyses: [ { filename: "01a252ba0708_car_8.jpg", raw_analysis: `[AI Observation] Vehicles Involved: 1 Make/Model: 2018 Toyota Highlander (Silver SUV) License Plate: CA 8XYZ123 Owner Details: Sarah Jenkins (DOB: 1985-11-20), Policy Status: ACTIVE [Condition Assessment] Tyre Condition: Front-Left (Good, 6mm tread), Front-Right (Good, 6mm tread), Rears (Worn, 3mm tread) [Damage Analysis & Insurance Assessment] - Front Bumper: Severe crush damage (Intensity: High). Insurance Clause 4A (Collision) -> FULLY COVERED - Radiator & Core Support: Destroyed (Intensity: Critical). Insurance Clause 4A (Collision) -> FULLY COVERED - Right Fender: Crumpled. Insurance Clause 4A (Collision) -> FULLY COVERED - Airbag Deployment: Driver and Passenger front active. Clause 5B (Safety Systems) -> COVERED [Summary] Severe front-end deformation observed consistent with high-speed frontal impact. Airbags deployed. Vehicle is likely a total loss.` } ], violations: [ { rule_id: "SPEED-001", rule_title: "Excessive Speed", severity: "CRITICAL", confidence: 0.92, party_label: "Silver SUV", evidence_summary: "Long skid marks and severe front-end crush profile indicate speeds greater than safe for conditions." }, { rule_id: "FOLLOW-001", rule_title: "Following Too Closely (Tailgating)", severity: "HIGH", confidence: 0.96, party_label: "Silver SUV", evidence_summary: "Impact pattern consistent with rear-ending a leading vehicle without sufficient braking distance." } ], fault_analysis: { determined: true, primary_fault_party: "Silver SUV", probable_cause: "Following too closely combined with excessive speed.", fault_distribution_json: { "Silver SUV": 100, "Other": 0 }, overall_confidence: 0.94, analysis_summary: "Based on severe front-end damage and skid marks, the Silver SUV is entirely at fault for failing to maintain a safe following distance and speed." } }, 102: { case: { id: 102, case_number: "INV-2026-043", officer_name: "Off. Davis", location: "Downtown Intersection", incident_date: "2026-03-02", status: "complete", created_at: "2026-03-02T10:30:00Z", photo_count: 2 }, photos: [ { id: "img_demo_4", filename: "a358e234c528_accident_scene_2.jpg", blob_url: "images/demo/a358e234c528_accident_scene_2.jpg" }, { id: "img_demo_5", filename: "e05bd82a2cfa_accident_9.jpg", blob_url: "images/demo/e05bd82a2cfa_accident_9.jpg" } ], analyses: [ { filename: "a358e234c528_accident_scene_2.jpg", raw_analysis: `[AI Observation] Vehicles Involved: 2 Vehicle A Make/Model: 2020 Honda Civic (Blue Sedan) Vehicle A License Plate: TX LMN-4567 Vehicle A Owner: David Chen (Policy: ABC-98765-Active) Vehicle B Make/Model: 2015 Ford F-150 (White Truck) Vehicle B License Plate: TX PQR-8910 Vehicle B Owner: M&M Deliveries Inc. (Commercial Policy: C-4455-Active) [Condition Assessment] Vehicle A Tyre Condition: All 4 Good (>5mm tread) Vehicle B Tyre Condition: Rear Left Bald (<2mm tread) - Potential hazard. [Damage Analysis & Insurance Assessment - Vehicle A] - Driver Side Doors (Front & Rear): Severe side impact damage (Intensity: High). Insurance Clause 2B (Not-at-fault Collision) -> SUBROGATION CLAIM - B-Pillar: Structural compromise. -> SUBROGATION CLAIM [Damage Analysis & Insurance Assessment - Vehicle B] - Front Bumper/Grille: Moderate damage (Intensity: Medium). Commercial Clause 1A (At-fault Liability) -> SUBJECT TO 000 DEDUCTIBLE [Summary] T-bone intersection collision. Blue Sedan sustained heavy side impact; White Truck shows front-end damage.` } ], violations: [ { rule_id: "SIGNAL-001", rule_title: "Red Light Violation", severity: "CRITICAL", confidence: 0.95, party_label: "White Truck", evidence_summary: "Point of impact and traffic cam footage suggests White Truck entered intersection after signal turned red." }, { rule_id: "MAINT-002", rule_title: "Defective Tires", severity: "HIGH", confidence: 0.88, party_label: "White Truck", evidence_summary: "Rear left tire on White Truck is severely worn (bald), reducing braking efficacy." } ], fault_analysis: { determined: true, primary_fault_party: "White Truck", probable_cause: "Failure to stop at red light.", fault_distribution_json: { "White Truck": 90, "Blue Sedan": 10 }, overall_confidence: 0.91, analysis_summary: "White Truck (Vehicle B) failed to stop for a red signal, directly causing the T-bone collision with the Blue Sedan. The bald tire on the truck exacerbated the inability to stop in time." } }, 103: { case: { id: 103, case_number: "INV-2026-044", officer_name: "Sgt. Miller", location: "Main St Bridge", incident_date: "2026-03-02", status: "ready", created_at: "2026-03-02T14:45:00Z", photo_count: 0 }, photos: [], analyses: [], violations: [], fault_analysis: null }, 104: { case: { id: 104, case_number: "INV-2026-045", officer_name: "Cpl. Ramirez", location: "I-80 Westbound", incident_date: "2026-03-03", status: "complete", created_at: "2026-03-03T09:15:00Z", photo_count: 1 }, photos: [ { id: "img_demo_104", filename: "car_10.jpg", blob_url: "images/demo/car_10.jpg" } ], analyses: [ { filename: "car_10.jpg", raw_analysis: `[AI Observation] Vehicles Involved: 1 Make/Model: 2022 Subaru Outback (Green) License Plate: NV 455-XYZ Owner Details: Elena Gomez, Policy #SUB-789-NV (Active) [Condition Assessment] Tyre Condition: Front right tire blown out. Other tires Good. Environmental factors: Wet road surface. [Damage Analysis & Insurance Assessment] - Front Right Quarter Panel: Scraped and dented (Intensity: Medium). Clause 4A -> FULLY COVERED - Front Right Wheel/Suspension: Broken control arm (Intensity: High). Clause 4A -> FULLY COVERED - Tire blowout: Not Covered (Mechanical wear exculsion Clause 7) [Summary] Single vehicle incident. Vehicle appears to have suffered a front right tire blowout leading to a collision with the highway barrier.` } ], violations: [ { rule_id: "SPEED-002", rule_title: "Too Fast for Conditions", severity: "HIGH", confidence: 0.78, party_label: "Green Subaru", evidence_summary: "Wet road surface indicates speed should have been reduced prior to loss of control." } ], fault_analysis: { determined: true, primary_fault_party: "Green Subaru", probable_cause: "Tire Blowout and exceeding safe speed for wet conditions.", fault_distribution_json: { "Green Subaru": 100 }, overall_confidence: 0.85, analysis_summary: "Driver lost control following a tire failure on wet pavement, striking the concrete barrier." } }, 105: { case: { id: 105, case_number: "INV-2026-046", officer_name: "Off. Jackson", location: "Elm Street Pedestrian Crossing", incident_date: "2026-03-03", status: "complete", created_at: "2026-03-03T11:20:00Z", photo_count: 1 }, photos: [ { id: "img_demo_105", filename: "accident_6.jpg", blob_url: "images/demo/accident_6.jpg" } ], analyses: [ { filename: "accident_6.jpg", raw_analysis: `[AI Observation] Vehicles Involved: 1 Make/Model: 2017 Nissan Altima (Black) License Plate: NY JKL-0123 Owner Details: Marcus Johnson, Policy NY-1122-ACTIVE Non-vehicle Parties: 1 Pedestrian [Condition Assessment] Tyre Condition: All tires adequate. [Damage Analysis & Insurance Assessment] - Front Hood & Windshield: Dented hood, spider-web shattered windshield (Intensity: High). Clause 4A -> COVERED - Pedestrian Injury Liability: Clause 1ABodily Injury -> COVERAGE TRIGGERED UP TO 50k LIMIT [Summary] Vehicle collided with a pedestrian on a marked crosswalk. Significant hood and windshield damage typical of a pedestrian strike at ~25-30mph.` } ], violations: [ { rule_id: "PEDES-003", rule_title: "Driver Failure to Stop for Pedestrian in Crosswalk", severity: "CRITICAL", confidence: 0.98, party_label: "Black Altima", evidence_summary: "Impact occurred directly on marked crosswalk paint." }, { rule_id: "SPEED-005", rule_title: "Failure to Reduce Speed in School Zone", severity: "CRITICAL", confidence: 0.90, party_label: "Black Altima", evidence_summary: "School zone markings active; impact damage indicates speed > 25mph." } ], fault_analysis: { determined: true, primary_fault_party: "Black Altima", probable_cause: "Failure to yield to pedestrian and speeding in school zone.", fault_distribution_json: { "Black Altima": 100, "Pedestrian": 0 }, overall_confidence: 0.99, analysis_summary: "Driver of the Black Altima is completely at fault for striking a pedestrian in a designated school zone crosswalk while failing to reduce speed." } } }; window.fetch = async (url, options = {}) => { // Only intercept our API calls if (typeof url === 'string' && url.startsWith('/api')) { console.log(`[Mock Demo API] ${options.method || 'GET'} ${url}`); // Helper to delay response const delay = ms => new Promise(resolve => setTimeout(resolve, ms)); // 1. Health check if (url.match(/\/api\/health$/)) { return new Response(JSON.stringify({ model_loaded: true, device: "Browser Demo", rules_loaded: 43 }), { status: 200 }); } // 2. Get cases (list) — renderCases expects flat objects: [{id, case_number, ...}] if (url.match(/\/api\/cases$/) && (options.method === 'GET' || !options.method)) { const casesList = Object.values(mockCases).map(c => c.case); return new Response(JSON.stringify({ cases: casesList }), { status: 200 }); } // 3. Create case if (url.match(/\/api\/cases$/) && options.method === 'POST') { const formData = options.body; const newCase = { id: mockNextId++, case_number: formData.get('case_number'), officer_name: formData.get('officer_name'), location: formData.get('location'), incident_date: formData.get('incident_date'), notes: formData.get('notes'), status: 'new', created_at: new Date().toISOString() }; mockCases[newCase.id] = { case: newCase, photos: [], violations: [], fault_analysis: null, analyses: [] }; return new Response(JSON.stringify(newCase), { status: 200 }); } // 3.5 Delete case if (url.match(/\/api\/cases\/\d+$/) && options.method === 'DELETE') { const id = parseInt(url.split('/').pop()); delete mockCases[id]; return new Response(JSON.stringify({ detail: "Deleted" }), { status: 200 }); } // 4. Upload photos if (url.match(/\/api\/cases\/\d+\/photos$/) && options.method === 'POST') { const match = url.match(/\/api\/cases\/(\d+)\/photos$/); const id = parseInt(match[1]); const formData = options.body; const files = formData.getAll('files'); await delay(800); // Simulate upload files.forEach(f => { mockCases[id].photos.push({ id: Math.random().toString(36).substr(2, 9), filename: f.name, blob_url: URL.createObjectURL(f) }); }); mockCases[id].case.photo_count = mockCases[id].photos.length; mockCases[id].case.status = 'ready'; return new Response(JSON.stringify({ count: files.length }), { status: 200 }); } // 5. Update case (PUT) if (url.match(/\/api\/cases\/\d+$/) && options.method === 'PUT') { const id = parseInt(url.split('/').pop()); if (!mockCases[id]) return new Response(JSON.stringify({ detail: "Not found" }), { status: 404 }); const formData = options.body; if (formData.get('officer_name')) mockCases[id].case.officer_name = formData.get('officer_name'); if (formData.get('location')) mockCases[id].case.location = formData.get('location'); if (formData.get('incident_date')) mockCases[id].case.incident_date = formData.get('incident_date'); if (formData.get('notes')) mockCases[id].case.notes = formData.get('notes'); return new Response(JSON.stringify(mockCases[id].case), { status: 200 }); } // 5b. Get case details — renderCaseDetail expects {case: {...}, photos: [...], ...} if (url.match(/\/api\/cases\/\d+$/) && (options.method === 'GET' || !options.method)) { const id = parseInt(url.split('/').pop()); if (!mockCases[id]) return new Response(JSON.stringify({ detail: "Not found" }), { status: 404 }); return new Response(JSON.stringify(mockCases[id]), { status: 200 }); } // 5c. Rules if (url.match(/\/api\/rules$/) && (options.method === 'GET' || !options.method)) { return new Response(JSON.stringify({ "categories": [{ "id": "SIGNAL", "name": "Traffic Signal Violations", "rule_count": 6, "rules": [{ "id": "SIGNAL-001", "title": "Red Light Violation", "description": "Proceeding through a red traffic signal without stopping", "severity": "CRITICAL", "visual_indicators": ["vehicle past stop line at intersection", "traffic signal showing red", "vehicle in middle of intersection", "collision at signalized intersection"], "fault_weight": 0.95, "applicable_parties": ["moving_vehicle"] }, { "id": "SIGNAL-002", "title": "Failure to Stop at Yellow Signal", "description": "Entering intersection on yellow when safe stop was possible", "severity": "HIGH", "visual_indicators": ["vehicle in intersection", "traffic signal showing yellow", "skid marks before intersection", "late braking evidence"], "fault_weight": 0.7, "applicable_parties": ["moving_vehicle"] }, { "id": "SIGNAL-003", "title": "Failure to Obey Stop Sign", "description": "Failing to come to a complete stop at a stop sign", "severity": "CRITICAL", "visual_indicators": ["stop sign visible", "vehicle past stop line", "no evidence of stopping", "collision near stop sign"], "fault_weight": 0.9, "applicable_parties": ["moving_vehicle"] }, { "id": "SIGNAL-004", "title": "Failure to Obey Yield Sign", "description": "Failing to yield right of way at a yield sign", "severity": "HIGH", "visual_indicators": ["yield sign visible", "vehicle entering without yielding", "merging collision"], "fault_weight": 0.8, "applicable_parties": ["moving_vehicle"] }, { "id": "SIGNAL-005", "title": "Disobeying Traffic Control Device", "description": "Ignoring any official traffic control sign or signal", "severity": "HIGH", "visual_indicators": ["no entry sign visible", "one way sign", "vehicle going wrong direction", "do not enter sign"], "fault_weight": 0.85, "applicable_parties": ["moving_vehicle"] }, { "id": "SIGNAL-006", "title": "Ignoring Railroad Crossing Signal", "description": "Proceeding through an active railroad crossing signal", "severity": "CRITICAL", "visual_indicators": ["railroad crossing", "crossing gates", "flashing lights at crossing"], "fault_weight": 0.95, "applicable_parties": ["moving_vehicle"] }] }, { "id": "LANE", "name": "Lane Discipline Violations", "rule_count": 8, "rules": [{ "id": "LANE-001", "title": "Wrong-Way Driving", "description": "Driving against the direction of traffic on a one-way road or divided highway", "severity": "CRITICAL", "visual_indicators": ["vehicle facing opposite direction", "head-on collision", "vehicle on wrong side of divided highway", "one way sign with vehicle going wrong direction"], "fault_weight": 0.98, "applicable_parties": ["moving_vehicle"] }, { "id": "LANE-002", "title": "Unsafe Lane Change", "description": "Changing lanes without signaling or when unsafe to do so", "severity": "HIGH", "visual_indicators": ["sideswipe damage", "contact marks on sides of vehicles", "vehicles in adjacent lanes", "paint transfer on vehicle sides"], "fault_weight": 0.75, "applicable_parties": ["moving_vehicle"] }, { "id": "LANE-003", "title": "Crossing Double Yellow Line", "description": "Crossing a double yellow center line to pass or turn", "severity": "HIGH", "visual_indicators": ["double yellow line visible", "vehicle across center line", "head-on or angular collision", "vehicle in opposing lane"], "fault_weight": 0.85, "applicable_parties": ["moving_vehicle"] }, { "id": "LANE-004", "title": "Failure to Maintain Lane", "description": "Drifting out of designated travel lane", "severity": "MEDIUM", "visual_indicators": ["vehicle partly in multiple lanes", "tire marks crossing lane lines", "sideswipe on lane boundary", "erratic tire tracks"], "fault_weight": 0.65, "applicable_parties": ["moving_vehicle"] }, { "id": "LANE-005", "title": "Improper Passing", "description": "Passing another vehicle illegally or unsafely", "severity": "HIGH", "visual_indicators": ["passing zone violation", "solid line crossing", "head-on collision in passing zone", "vehicle in oncoming lane"], "fault_weight": 0.8, "applicable_parties": ["moving_vehicle"] }, { "id": "LANE-006", "title": "Driving on Shoulder", "description": "Operating a vehicle on the road shoulder", "severity": "MEDIUM", "visual_indicators": ["vehicle on shoulder", "tire marks on shoulder", "collision on road shoulder", "gravel spray from shoulder"], "fault_weight": 0.6, "applicable_parties": ["moving_vehicle"] }, { "id": "LANE-007", "title": "Improper U-Turn", "description": "Making a U-turn where prohibited or unsafe", "severity": "HIGH", "visual_indicators": ["vehicle turned perpendicular to traffic", "no u-turn sign", "vehicle blocking lanes during turn"], "fault_weight": 0.8, "applicable_parties": ["moving_vehicle"] }, { "id": "LANE-008", "title": "Failure to Use Turn Signal", "description": "Turning or changing lanes without activating turn signal", "severity": "MEDIUM", "visual_indicators": ["no turn signal visible during maneuver", "sudden lane change", "unexpected turn collision"], "fault_weight": 0.55, "applicable_parties": ["moving_vehicle"] }] }, { "id": "SPEED", "name": "Speed Related Violations", "rule_count": 5, "rules": [{ "id": "SPEED-001", "title": "Excessive Speed", "description": "Traveling at a speed greater than posted limit or unsafe for conditions", "severity": "CRITICAL", "visual_indicators": ["long skid marks", "severe impact damage", "extensive debris field", "vehicle displacement after impact", "deployed airbags", "significant vehicle deformation"], "fault_weight": 0.85, "applicable_parties": ["moving_vehicle"] }, { "id": "SPEED-002", "title": "Too Fast for Conditions", "description": "Driving too fast for weather, road, or traffic conditions", "severity": "HIGH", "visual_indicators": ["wet road surface", "skid marks on wet road", "reduced visibility conditions", "loss of control evidence", "single vehicle off-road"], "fault_weight": 0.75, "applicable_parties": ["moving_vehicle"] }, { "id": "SPEED-003", "title": "Speed Too Slow", "description": "Driving unreasonably slow and impeding traffic", "severity": "LOW", "visual_indicators": ["rear-end collision", "slow vehicle on highway", "hazard lights on moving vehicle"], "fault_weight": 0.4, "applicable_parties": ["moving_vehicle"] }, { "id": "SPEED-004", "title": "Racing or Speed Contest", "description": "Engaging in speed competition on public roads", "severity": "CRITICAL", "visual_indicators": ["multiple vehicles with severe damage", "very long skid marks", "extreme vehicle deformation", "vehicle rollover"], "fault_weight": 0.9, "applicable_parties": ["moving_vehicle"] }, { "id": "SPEED-005", "title": "Failure to Reduce Speed in School Zone", "description": "Not reducing speed in a designated school zone", "severity": "CRITICAL", "visual_indicators": ["school zone sign", "school crossing", "pedestrian collision near school", "school zone flashing lights"], "fault_weight": 0.9, "applicable_parties": ["moving_vehicle"] }] }, { "id": "YIELD", "name": "Right of Way Violations", "rule_count": 7, "rules": [{ "id": "YIELD-001", "title": "Failure to Yield at Intersection", "description": "Not yielding right of way to cross traffic at an intersection", "severity": "HIGH", "visual_indicators": ["T-bone collision at intersection", "side impact at intersection", "vehicle entering intersection from side street", "angular collision"], "fault_weight": 0.85, "applicable_parties": ["moving_vehicle"] }, { "id": "YIELD-002", "title": "Failure to Yield When Turning Left", "description": "Not yielding to oncoming traffic when making a left turn", "severity": "HIGH", "visual_indicators": ["left-turning vehicle struck", "front-corner impact on turning vehicle", "collision in intersection during left turn"], "fault_weight": 0.85, "applicable_parties": ["moving_vehicle"] }, { "id": "YIELD-003", "title": "Failure to Yield to Pedestrian", "description": "Not yielding right of way to pedestrian in crosswalk", "severity": "CRITICAL", "visual_indicators": ["pedestrian in crosswalk", "pedestrian struck", "crosswalk markings visible", "pedestrian crossing sign"], "fault_weight": 0.95, "applicable_parties": ["moving_vehicle"] }, { "id": "YIELD-004", "title": "Failure to Yield When Merging", "description": "Not yielding when entering a highway or merging into traffic", "severity": "HIGH", "visual_indicators": ["merge lane collision", "on-ramp collision", "merging vehicle sideswipe", "acceleration lane impact"], "fault_weight": 0.8, "applicable_parties": ["moving_vehicle"] }, { "id": "YIELD-005", "title": "Failure to Yield to Emergency Vehicle", "description": "Not pulling over for an emergency vehicle with lights/sirens", "severity": "CRITICAL", "visual_indicators": ["emergency vehicle involved", "police car, ambulance, or fire truck", "emergency lights visible"], "fault_weight": 0.9, "applicable_parties": ["moving_vehicle"] }, { "id": "YIELD-006", "title": "Failure to Yield at Roundabout", "description": "Not yielding to traffic already in a roundabout", "severity": "HIGH", "visual_indicators": ["roundabout collision", "circular intersection", "vehicle entering roundabout", "yield sign at roundabout"], "fault_weight": 0.8, "applicable_parties": ["moving_vehicle"] }, { "id": "YIELD-007", "title": "Right of Way at Uncontrolled Intersection", "description": "Failure to yield to vehicle on the right at an uncontrolled intersection", "severity": "HIGH", "visual_indicators": ["intersection without signals or signs", "side impact collision", "residential intersection collision"], "fault_weight": 0.7, "applicable_parties": ["moving_vehicle"] }] }, { "id": "FOLLOW", "name": "Following Distance Violations", "rule_count": 2, "rules": [{ "id": "FOLLOW-001", "title": "Following Too Closely (Tailgating)", "description": "Not maintaining a safe following distance behind another vehicle", "severity": "HIGH", "visual_indicators": ["rear-end collision", "front damage on following vehicle", "rear damage on lead vehicle", "chain reaction collision", "short skid marks before impact"], "fault_weight": 0.9, "applicable_parties": ["following_vehicle"] }, { "id": "FOLLOW-002", "title": "Sudden Stop Causing Rear Collision", "description": "Stopping abruptly without justification causing following vehicle to collide", "severity": "MEDIUM", "visual_indicators": ["rear-end collision", "no apparent hazard ahead", "brake lights"], "fault_weight": 0.4, "applicable_parties": ["lead_vehicle"] }] }, { "id": "MAINT", "name": "Vehicle Maintenance Violations", "rule_count": 6, "rules": [{ "id": "MAINT-001", "title": "Defective Brakes", "description": "Operating vehicle with defective or inadequate braking system", "severity": "CRITICAL", "visual_indicators": ["very long skid marks", "no skid marks despite high-speed impact", "brake fluid on road", "brake component debris"], "fault_weight": 0.7, "applicable_parties": ["vehicle_owner"] }, { "id": "MAINT-002", "title": "Defective Tires", "description": "Operating vehicle with bald, damaged, or improper tires", "severity": "HIGH", "visual_indicators": ["tire blowout debris", "bald tires visible", "irregular tire marks", "tire tread separation"], "fault_weight": 0.65, "applicable_parties": ["vehicle_owner"] }, { "id": "MAINT-003", "title": "Defective Lights", "description": "Operating vehicle with non-functional headlights, taillights, or signals", "severity": "MEDIUM", "visual_indicators": ["broken headlight pre-collision", "no tail lights", "nighttime collision with unlit vehicle"], "fault_weight": 0.55, "applicable_parties": ["vehicle_owner"] }, { "id": "MAINT-004", "title": "Obstructed View", "description": "Driving with obstructed windshield or mirrors", "severity": "MEDIUM", "visual_indicators": ["cracked windshield", "obstructed view", "missing mirrors", "tinted windows too dark"], "fault_weight": 0.5, "applicable_parties": ["vehicle_owner"] }, { "id": "MAINT-005", "title": "Unsecured Load", "description": "Carrying unsecured cargo that falls onto roadway", "severity": "HIGH", "visual_indicators": ["cargo on road", "items fallen from vehicle", "truck load spill", "debris from cargo"], "fault_weight": 0.75, "applicable_parties": ["vehicle_owner"] }, { "id": "MAINT-006", "title": "Vehicle Overloaded", "description": "Vehicle loaded beyond safe operating capacity", "severity": "MEDIUM", "visual_indicators": ["visibly overloaded vehicle", "sagging suspension", "items stacked above roof"], "fault_weight": 0.55, "applicable_parties": ["vehicle_owner"] }] }, { "id": "PEDES", "name": "Pedestrian Related Violations", "rule_count": 5, "rules": [{ "id": "PEDES-001", "title": "Pedestrian Jaywalking", "description": "Pedestrian crossing road outside of designated crosswalk", "severity": "MEDIUM", "visual_indicators": ["pedestrian outside crosswalk", "pedestrian crossing mid-block", "no crosswalk markings at impact location"], "fault_weight": 0.5, "applicable_parties": ["pedestrian"] }, { "id": "PEDES-002", "title": "Pedestrian Against Signal", "description": "Pedestrian crossing against a don't walk signal", "severity": "HIGH", "visual_indicators": ["pedestrian signal showing do not walk", "pedestrian in crosswalk against signal"], "fault_weight": 0.7, "applicable_parties": ["pedestrian"] }, { "id": "PEDES-003", "title": "Driver Failure to Stop for Pedestrian in Crosswalk", "description": "Driver not stopping for pedestrian in a marked or unmarked crosswalk", "severity": "CRITICAL", "visual_indicators": ["pedestrian struck in crosswalk", "crosswalk markings visible", "pedestrian crossing sign"], "fault_weight": 0.95, "applicable_parties": ["moving_vehicle"] }, { "id": "PEDES-004", "title": "Pedestrian on Highway", "description": "Pedestrian walking on a limited-access highway", "severity": "HIGH", "visual_indicators": ["pedestrian on highway", "limited access road", "no pedestrian facilities"], "fault_weight": 0.6, "applicable_parties": ["pedestrian"] }, { "id": "PEDES-005", "title": "Failure to Stop for School Bus", "description": "Passing a stopped school bus with flashing lights", "severity": "CRITICAL", "visual_indicators": ["school bus with stop sign extended", "school bus with flashing lights", "children near school bus"], "fault_weight": 0.95, "applicable_parties": ["moving_vehicle"] }] }, { "id": "ROAD", "name": "Road Condition Violations", "rule_count": 4, "rules": [{ "id": "ROAD-001", "title": "Hazardous Road Condition", "description": "Road surface or design defect contributing to accident", "severity": "MEDIUM", "visual_indicators": ["pothole visible", "road surface damage", "missing road markings", "inadequate signage", "construction zone without proper markings"], "fault_weight": 0.3, "applicable_parties": ["road_authority"] }, { "id": "ROAD-002", "title": "Inadequate Lighting", "description": "Road segment without adequate lighting contributing to collision", "severity": "MEDIUM", "visual_indicators": ["dark road", "no street lights", "nighttime collision in unlit area"], "fault_weight": 0.25, "applicable_parties": ["road_authority"] }, { "id": "ROAD-003", "title": "Missing or Damaged Guard Rail", "description": "Absent or damaged barrier that could have prevented injury", "severity": "MEDIUM", "visual_indicators": ["vehicle off road", "no guard rail on curve", "damaged guard rail", "vehicle in ditch or embankment"], "fault_weight": 0.2, "applicable_parties": ["road_authority"] }, { "id": "ROAD-004", "title": "Work Zone / Construction Hazard", "description": "Improperly marked or protected construction zone", "severity": "HIGH", "visual_indicators": ["construction zone", "work zone signs", "cones, barrels, barriers", "lane closure", "construction equipment on road"], "fault_weight": 0.35, "applicable_parties": ["road_authority", "moving_vehicle"] }] }] }), { status: 200 }); } // 6. Run AI Analysis if (url.match(/\/api\/cases\/\d+\/analyze$/) && options.method === 'POST') { const match = url.match(/\/api\/cases\/(\d+)\/analyze$/); const id = parseInt(match[1]); await delay(3000); // Simulate heavy AI reasoning mockCases[id].case.status = 'complete'; // Generate mock analysis data based on the uploaded photos mockCases[id].analyses = mockCases[id].photos.map(p => ({ filename: p.filename, raw_analysis: `[AI Observation]\nSubject: Vehicle A and Vehicle B.\nAction: Vehicle A exhibits front-end damage consistent with forward impact. Vehicle B exhibits rear damage. Road conditions appear clear.` })); mockCases[id].violations = [ { rule_id: "Article 18", rule_title: "Failure to Leave Safe Distance", severity: "HIGH", confidence: 0.94, party_label: "Vehicle A", evidence_summary: "Visual estimation of skid marks and impact deformation indicates Vehicle A failed to maintain adequate stopping distance." } ]; mockCases[id].fault_analysis = { determined: true, primary_fault_party: "Vehicle A", probable_cause: "Rear-end collision due to insufficient following distance.", fault_distribution_json: { "Vehicle A": 100, "Vehicle B": 0 }, overall_confidence: 0.92, analysis_summary: "Based on the visual evidence, Vehicle A is fully at fault for failing to stop in time." }; return new Response(JSON.stringify({ violations_found: 1 }), { status: 200 }); } // 7. Get Report if (url.match(/\/api\/cases\/\d+\/report$/) && (options.method === 'GET' || !options.method)) { const match = url.match(/\/api\/cases\/(\d+)\/report$/); const id = parseInt(match[1]); const c = mockCases[id]; const report = { case: c.case, report_type: 'AI Accident Analysis Report', disclaimer: 'This is a mock report for demo purposes.', statistics: { total_photos: c.photos.length, total_violations: c.violations.length, critical_violations: c.violations.filter(v => v.severity === 'HIGH').length, parties_identified: Object.keys(c.fault_analysis?.fault_distribution_json || {}).length }, scene_summary: "AI review of the provided imagery indicates a multi-vehicle traffic incident resulting in property damage.", parties: c.fault_analysis ? Object.keys(c.fault_analysis.fault_distribution_json).map(k => ({ label: k, vehicle_type: 'Automobile', vehicle_color: 'Unknown' })) : [], violations: { list: c.violations.map(v => ({ title: v.rule_title, severity: v.severity, party: v.party_label, confidence: v.confidence, evidence: v.evidence_summary })) }, fault_analysis: c.fault_analysis }; return new Response(JSON.stringify(report), { status: 200 }); } } // Intercept photo image requests to return the stored blob URL instead if (typeof url === 'string' && url.match(/\/api\/photos\/(.+)\/image$/)) { const match = url.match(/\/api\/photos\/(.+)\/image$/); const photoId = match[1]; let foundPhotoURL = null; Object.values(mockCases).forEach(c => { const p = c.photos.find(p => p.id === photoId); if (p) foundPhotoURL = p.blob_url; }); if (foundPhotoURL) { // Fetch the blob URL to return a proper image response return originalFetch(foundPhotoURL); } } // Fallback for non-API requests return originalFetch(url, options); }; // ── State ───────────────────────────────────────────────────────────── let currentView = 'landing'; let currentCaseId = null; let currentCaseData = null; let currentVertical = 'le'; // Store full case data for editing let selectedFiles = []; let additionalFiles = []; // For add photos modal // ── Init ────────────────────────────────────────────────────────────── document.addEventListener('DOMContentLoaded', () => { initNavigation(); initDropZone(); initButtons(); initAccordions(); loadHealth(); loadCases(); }); // ── Accordions ──────────────────────────────────────────────────────── function initAccordions() { document.querySelectorAll('.accordion-header').forEach(btn => { btn.addEventListener('click', () => { const item = btn.closest('.accordion-item'); const isActive = item.classList.contains('active'); // Close all other accordions document.querySelectorAll('.accordion-item').forEach(i => { i.classList.remove('active'); i.querySelector('.accordion-header').setAttribute('aria-expanded', 'false'); }); // Toggle current if (!isActive) { item.classList.add('active'); btn.setAttribute('aria-expanded', 'true'); } }); }); } // ── Navigation ──────────────────────────────────────────────────────── function initNavigation() { document.querySelectorAll('.nav-item[data-view]').forEach(btn => { btn.addEventListener('click', () => { const view = btn.dataset.view; switchView(view); document.querySelectorAll('.nav-item').forEach(b => b.classList.remove('active')); btn.classList.add('active'); }); }); } function switchView(viewName) { document.querySelectorAll('.view').forEach(v => { v.classList.remove('active'); v.classList.add('hidden'); }); const target = document.getElementById(`view-${viewName}`); if (target) { target.classList.remove('hidden'); target.classList.add('active'); } currentView = viewName; if (viewName === 'le-dashboard') loadCases('le'); if (viewName === 'ins-dashboard') loadCases('ins'); if (viewName === 'rules') loadRules(); } // ── Buttons ─────────────────────────────────────────────────────────── function initButtons() { // LE Dashboard document.getElementById('btn-refresh-cases')?.addEventListener('click', () => loadCases('le')); document.getElementById('btn-le-empty-new-case')?.addEventListener('click', () => { switchView('le-new-case'); document.querySelectorAll('.nav-item').forEach(b => b.classList.remove('active')); document.querySelector('[data-view="le-new-case"]')?.classList.add('active'); }); // INS Dashboard document.getElementById('btn-refresh-cases-ins')?.addEventListener('click', () => loadCases('ins')); document.getElementById('btn-ins-empty-new-case')?.addEventListener('click', () => { switchView('ins-new-case'); document.querySelectorAll('.nav-item').forEach(b => b.classList.remove('active')); document.querySelector('[data-view="ins-new-case"]')?.classList.add('active'); }); // New Case Forms document.getElementById('btn-create-case')?.addEventListener('click', () => createCase('le')); document.getElementById('btn-ins-create-case')?.addEventListener('click', () => createCase('ins')); // Case Detail document.getElementById('btn-back-dashboard')?.addEventListener('click', () => switchView(currentVertical === 'ins' ? 'ins-dashboard' : 'le-dashboard')); document.getElementById('btn-run-analysis')?.addEventListener('click', runAnalysis); document.getElementById('btn-view-report')?.addEventListener('click', viewReport); document.getElementById('btn-edit-case')?.addEventListener('click', openEditModal); document.getElementById('btn-add-photos-inline')?.addEventListener('click', openAddPhotosModal); // Edit Modal document.getElementById('btn-close-edit-modal')?.addEventListener('click', closeEditModal); document.getElementById('btn-cancel-edit')?.addEventListener('click', closeEditModal); document.getElementById('btn-save-case')?.addEventListener('click', saveCaseChanges); // Photos Modal document.getElementById('btn-close-photos-modal')?.addEventListener('click', closePhotosModal); document.getElementById('btn-cancel-photos')?.addEventListener('click', closePhotosModal); document.getElementById('btn-upload-more')?.addEventListener('click', uploadMorePhotos); // Form logic document.getElementById('case-number')?.addEventListener('input', () => validateForm('le')); document.getElementById('ins-case-number')?.addEventListener('input', () => validateForm('ins')); initEditDropZone(); } function initEditDropZone() { const dropZone = document.getElementById('edit-drop-zone'); const fileInput = document.getElementById('edit-file-input'); if (!dropZone || !fileInput) return; dropZone.addEventListener('click', () => fileInput.click()); dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('drag-over'); }); dropZone.addEventListener('dragleave', () => dropZone.classList.remove('drag-over')); dropZone.addEventListener('drop', e => { e.preventDefault(); dropZone.classList.remove('drag-over'); handleAdditionalFiles(Array.from(e.dataTransfer.files)); }); fileInput.addEventListener('change', () => { handleAdditionalFiles(Array.from(fileInput.files)); }); } function validateForm(vertical = 'le') { const cid = vertical === 'le' ? 'case-number' : 'ins-case-number'; const bid = vertical === 'le' ? 'btn-create-case' : 'btn-ins-create-case'; const caseNum = document.getElementById(cid)?.value.trim(); const btn = document.getElementById(bid); if (btn) btn.disabled = !caseNum; } // ── Health ───────────────────────────────────────────────────────────── async function loadHealth() { try { const resp = await fetch(`${API_BASE}/health`); const data = await resp.json(); document.getElementById('status-model').textContent = data.model_loaded ? '✓ Ready' : '✗ Not loaded'; document.getElementById('status-model').style.color = data.model_loaded ? '#22c55e' : '#ef4444'; document.getElementById('status-device').textContent = data.device || '—'; document.getElementById('status-rules').textContent = `${data.rules_loaded} rules`; } catch { document.getElementById('status-model').textContent = '✗ Offline'; document.getElementById('status-model').style.color = '#ef4444'; } } // ── Cases ───────────────────────────────────────────────────────────── async function loadCases(vertical = 'le') { currentVertical = vertical; try { const resp = await fetch(`${API_BASE}/cases`); const data = await resp.json(); renderCases(data.cases || [], vertical); } catch (e) { showToast('Failed to load cases', 'error'); } } function renderCases(cases, vertical) { const grid = document.getElementById(vertical === 'le' ? 'le-cases-grid' : 'ins-cases-grid'); const empty = document.getElementById(vertical === 'le' ? 'le-empty-state-dashboard' : 'ins-empty-state-dashboard'); if (!cases.length) { grid.innerHTML = ''; grid.style.display = 'none'; empty.style.display = 'flex'; return; } grid.style.display = 'grid'; empty.style.display = 'none'; grid.innerHTML = cases.map(c => `
${escHtml(c.case_number)} ${c.status}
${c.officer_name ? ` ${escHtml(c.officer_name)}` : ''} ${c.location ? ` ${escHtml(c.location)}` : ''} ${c.incident_date ? ` ${c.incident_date}` : ''}
`).join(''); } async function deleteCase(id, caseNum, vertical = 'le') { if (!confirm(`Are you sure you want to delete Case ${caseNum}? This will permanently remove all photos and analysis data.`)) { return; } try { const resp = await fetch(`${API_BASE}/cases/${id}`, { method: 'DELETE' }); if (!resp.ok) throw new Error('Delete failed'); showToast(`Case ${caseNum} deleted`, 'success'); loadCases(); } catch (e) { showToast(e.message, 'error'); } } // ── Create Case ─────────────────────────────────────────────────────── async function createCase(vertical = 'le') { const cid = vertical === 'le' ? 'case-number' : 'ins-case-number'; const oid = vertical === 'le' ? 'officer-name' : 'ins-officer-name'; const lid = vertical === 'le' ? 'incident-location' : 'ins-incident-location'; const did = vertical === 'le' ? 'incident-date' : 'ins-incident-date'; const nid = vertical === 'le' ? 'officer-notes' : 'ins-officer-notes'; const caseNumber = document.getElementById(cid).value.trim(); if (!caseNumber) return showToast('Case number is required', 'error'); const formData = new FormData(); formData.append('case_number', caseNumber); formData.append('officer_name', document.getElementById(oid).value.trim()); formData.append('location', document.getElementById(lid).value.trim()); formData.append('incident_date', document.getElementById(did).value); formData.append('notes', document.getElementById(nid).value.trim()); try { const resp = await fetch(`${API_BASE}/cases`, { method: 'POST', body: formData }); if (!resp.ok) { const err = await resp.json(); throw new Error(err.detail || 'Failed to create case'); } const data = await resp.json(); const caseId = data.id; showToast('Case created', 'success'); // Upload photos if selected if (selectedFiles.length > 0) { await uploadPhotos(caseId, vertical); } // Clear form document.getElementById(cid).value = ''; document.getElementById(oid).value = ''; document.getElementById(lid).value = ''; document.getElementById(did).value = ''; document.getElementById(nid).value = ''; selectedFiles = []; document.getElementById(vertical === 'le' ? 'photo-preview' : 'ins-photo-preview').classList.add('hidden'); // Open the case detail openCase(caseId); } catch (e) { showToast(e.message, 'error'); } } async function uploadPhotos(caseId, vertical = 'le') { const pfx = vertical === 'le' ? '' : 'ins-'; const progressSection = document.getElementById(`${pfx}upload-progress`); const progressFill = document.getElementById(`${pfx}upload-progress-fill`); const statusText = document.getElementById(`${pfx}upload-status-text`); progressSection.classList.remove('hidden'); statusText.textContent = `Uploading ${selectedFiles.length} photos...`; const formData = new FormData(); selectedFiles.forEach(f => formData.append('files', f)); try { progressFill.style.width = '50%'; const resp = await fetch(`${API_BASE}/cases/${caseId}/photos`, { method: 'POST', body: formData, }); if (!resp.ok) throw new Error('Upload failed'); const data = await resp.json(); progressFill.style.width = '100%'; statusText.textContent = `${data.count} photos uploaded ✓`; showToast(`${data.count} photos uploaded`, 'success'); return data; } catch (e) { statusText.textContent = 'Upload failed'; showToast('Photo upload failed', 'error'); throw e; } } // ── Drop Zone ───────────────────────────────────────────────────────── function initDropZone() { setupZone('drop-zone', 'file-input', 'photo-preview', 'photo-thumbnails', 'photo-count'); setupZone('ins-drop-zone', 'ins-file-input', 'ins-photo-preview', 'ins-photo-thumbnails', 'ins-photo-count'); } function setupZone(dzId, inId, prvId, tnId, ctId) { const dropZone = document.getElementById(dzId); const fileInput = document.getElementById(inId); if (!dropZone || !fileInput) return; dropZone.addEventListener('click', () => fileInput.click()); dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('drag-over'); }); dropZone.addEventListener('dragleave', () => dropZone.classList.remove('drag-over')); dropZone.addEventListener('drop', e => { e.preventDefault(); dropZone.classList.remove('drag-over'); handleFiles(Array.from(e.dataTransfer.files), prvId, tnId, ctId); }); fileInput.addEventListener('change', () => { handleFiles(Array.from(fileInput.files), prvId, tnId, ctId, dzId.startsWith('ins') ? 'ins' : 'le'); }); } function handleFiles(files, prvId = 'photo-preview', tnId = 'photo-thumbnails', ctId = 'photo-count', prefix = 'le') { const imageFiles = files.filter(f => f.type.startsWith('image/')); if (!imageFiles.length) return showToast('No image files found', 'error'); selectedFiles = imageFiles.slice(0, 20); // Max 20 // Show previews const preview = document.getElementById(prvId); const thumbs = document.getElementById(tnId); const count = document.getElementById(ctId); preview.classList.remove('hidden'); count.textContent = `${selectedFiles.length} photo${selectedFiles.length > 1 ? 's' : ''} selected`; thumbs.innerHTML = ''; selectedFiles.forEach(file => { const div = document.createElement('div'); div.className = 'photo-thumbnail'; const img = document.createElement('img'); img.src = URL.createObjectURL(file); div.appendChild(img); thumbs.appendChild(div); }); validateForm(prefix); } // ── Open Case Detail ────────────────────────────────────────────────── async function openCase(caseId) { currentCaseId = caseId; switchView('case-detail'); try { const resp = await fetch(`${API_BASE}/cases/${caseId}`); const data = await resp.json(); currentCaseData = data.case; renderCaseDetail(data); } catch (e) { showToast('Failed to load case', 'error'); } } // ── Edit Case ───────────────────────────────────────────────────────── function openEditModal() { if (!currentCaseData) return; document.getElementById('edit-officer-name').value = currentCaseData.officer_name || ''; document.getElementById('edit-incident-location').value = currentCaseData.location || ''; document.getElementById('edit-incident-date').value = currentCaseData.incident_date || ''; document.getElementById('edit-officer-notes').value = currentCaseData.notes || ''; document.getElementById('modal-edit-case').classList.remove('hidden'); } function closeEditModal() { document.getElementById('modal-edit-case').classList.add('hidden'); } async function saveCaseChanges() { if (!currentCaseId) return; const formData = new FormData(); formData.append('officer_name', document.getElementById('edit-officer-name').value.trim()); formData.append('location', document.getElementById('edit-incident-location').value.trim()); formData.append('incident_date', document.getElementById('edit-incident-date').value); formData.append('notes', document.getElementById('edit-officer-notes').value.trim()); try { const resp = await fetch(`${API_BASE}/cases/${currentCaseId}`, { method: 'PUT', body: formData }); if (!resp.ok) throw new Error('Failed to update case'); showToast('Case updated', 'success'); closeEditModal(); openCase(currentCaseId); // Refresh } catch (e) { showToast(e.message, 'error'); } } // ── Add Photos ──────────────────────────────────────────────────────── function openAddPhotosModal() { additionalFiles = []; document.getElementById('edit-photo-preview').classList.add('hidden'); document.getElementById('edit-upload-progress').classList.add('hidden'); document.getElementById('btn-upload-more').disabled = true; document.getElementById('modal-add-photos').classList.remove('hidden'); } function closePhotosModal() { document.getElementById('modal-add-photos').classList.add('hidden'); } function handleAdditionalFiles(files) { const imageFiles = files.filter(f => f.type.startsWith('image/')); if (!imageFiles.length) return showToast('No image files found', 'error'); additionalFiles = imageFiles.slice(0, 20); const preview = document.getElementById('edit-photo-preview'); const thumbs = document.getElementById('edit-photo-thumbnails'); const count = document.getElementById('edit-photo-count'); preview.classList.remove('hidden'); count.textContent = `${additionalFiles.length} photo${additionalFiles.length > 1 ? 's' : ''} selected`; thumbs.innerHTML = ''; additionalFiles.forEach(file => { const div = document.createElement('div'); div.className = 'photo-thumbnail'; const img = document.createElement('img'); img.src = URL.createObjectURL(file); div.appendChild(img); thumbs.appendChild(div); }); document.getElementById('btn-upload-more').disabled = additionalFiles.length === 0; } async function uploadMorePhotos() { if (!currentCaseId || !additionalFiles.length) return; const progressSection = document.getElementById('edit-upload-progress'); const progressFill = document.getElementById('edit-upload-progress-fill'); const statusText = document.getElementById('edit-upload-status-text'); progressSection.classList.remove('hidden'); document.getElementById('btn-upload-more').disabled = true; const formData = new FormData(); additionalFiles.forEach(f => formData.append('files', f)); try { progressFill.style.width = '50%'; const resp = await fetch(`${API_BASE}/cases/${currentCaseId}/photos`, { method: 'POST', body: formData, }); if (!resp.ok) throw new Error('Upload failed'); progressFill.style.width = '100%'; statusText.textContent = `Upload complete ✓`; showToast('Photos added successfully', 'success'); setTimeout(() => { closePhotosModal(); openCase(currentCaseId); // Refresh }, 1000); } catch (e) { statusText.textContent = 'Upload failed'; showToast('Photo upload failed', 'error'); document.getElementById('btn-upload-more').disabled = false; } } function renderCaseDetail(data) { const c = data.case; // Header document.getElementById('case-detail-title').textContent = `Case: ${c.case_number}`; // Info bar document.getElementById('detail-case-number').textContent = c.case_number; document.getElementById('detail-officer').textContent = c.officer_name || 'N/A'; document.getElementById('detail-location').textContent = c.location || 'N/A'; document.getElementById('detail-date').textContent = c.incident_date || 'N/A'; document.getElementById('detail-status').textContent = c.status; const statusChip = document.getElementById('detail-status-chip'); statusChip.className = `info-chip status-chip ${c.status}`; // Enable/disable buttons const btnAnalysis = document.getElementById('btn-run-analysis'); const btnReport = document.getElementById('btn-view-report'); // Global disable of 'RUN AI ANALYTICS' for all cases in UI-only demo btnAnalysis.disabled = true; btnAnalysis.title = "AI Analytics is currently disabled"; btnReport.disabled = c.status !== 'complete'; // Photos const photosGrid = document.getElementById('detail-photos-grid'); document.getElementById('photo-badge').textContent = data.photos?.length || 0; if (data.photos?.length) { photosGrid.innerHTML = data.photos.map(p => `
${escHtml(p.filename)}
`).join(''); } else { photosGrid.innerHTML = '

No photos uploaded yet.

'; } // Analyses const analysisContent = document.getElementById('analysis-content'); if (data.analyses?.length) { analysisContent.innerHTML = data.analyses.map(a => `
📷 ${escHtml(a.filename || 'Photo')}
${escHtml(a.raw_analysis || '')}
`).join(''); } else { analysisContent.innerHTML = '

Run analysis to see AI observations.

'; } // Violations const violationsList = document.getElementById('violations-list'); const violationBadge = document.getElementById('violation-badge'); violationBadge.textContent = data.violations?.length || 0; if (data.violations?.length) { violationsList.innerHTML = data.violations.map(v => `
${escHtml(v.rule_title)} ${v.severity || 'MEDIUM'}
${v.rule_id} ${Math.round(v.confidence * 100)}% confidence ${v.party_label ? ` ${escHtml(v.party_label)}` : ''}
${v.evidence_summary ? `
${escHtml(v.evidence_summary)}
` : ''}
`).join(''); } else { violationsList.innerHTML = '

No violations detected yet.

'; } // Fault Analysis renderFaultAnalysis(data.fault_analysis, data.parties); } function renderFaultAnalysis(fault, parties) { const content = document.getElementById('fault-content'); if (!fault) { content.innerHTML = '

Run analysis to determine fault.

'; return; } let html = ''; // Fault distribution bars if (fault.fault_distribution_json) { let dist = {}; try { dist = typeof fault.fault_distribution_json === 'string' ? JSON.parse(fault.fault_distribution_json) : fault.fault_distribution_json; } catch { dist = {}; } const entries = Object.entries(dist); if (entries.length) { html += '
'; entries.forEach(([label, pct], i) => { html += `
${escHtml(label)} ${pct}%
`; }); html += '
'; } } // Probable cause if (fault.probable_cause) { html += `

Probable Cause

${escHtml(fault.probable_cause)}

`; } // Confidence if (fault.overall_confidence != null) { const pct = Math.round(fault.overall_confidence * 100); html += `
Confidence:
${pct}%
`; } // Summary if (fault.analysis_summary) { html += `

${escHtml(fault.analysis_summary)}

`; } content.innerHTML = html || '

No fault analysis available.

'; } // ── Run Analysis ────────────────────────────────────────────────────── async function runAnalysis() { if (!currentCaseId) return; const overlay = document.getElementById('analysis-overlay'); const stepEl = document.getElementById('analysis-step'); const detailEl = document.getElementById('analysis-detail'); overlay.classList.remove('hidden'); stepEl.textContent = 'Analyzing accident scene photos...'; detailEl.textContent = 'Running AI vision analysis on each photo. This may take several minutes.'; try { const resp = await fetch(`${API_BASE}/cases/${currentCaseId}/analyze`, { method: 'POST', }); if (!resp.ok) { const err = await resp.json(); throw new Error(err.detail || 'Analysis failed'); } const data = await resp.json(); overlay.classList.add('hidden'); showToast(`Analysis complete: ${data.violations_found} violations found`, 'success'); // Reload case detail openCase(currentCaseId); } catch (e) { overlay.classList.add('hidden'); showToast(`Analysis failed: ${e.message}`, 'error'); } } // ── Report ──────────────────────────────────────────────────────────── async function viewReport() { if (!currentCaseId) return; switchView('report'); try { const resp = await fetch(`${API_BASE}/cases/${currentCaseId}/report`); const report = await resp.json(); renderReport(report); } catch (e) { showToast('Failed to load report', 'error'); } } function renderReport(report) { const content = document.getElementById('report-content'); let html = `

${report.report_type || 'Incident Report'}

Case ${escHtml(report.case?.case_number || '')} • Generated by AI

${escHtml(report.disclaimer || '')}
`; // Stats const stats = report.statistics || {}; html += `
${stats.total_photos || 0}
Photos
${stats.total_violations || 0}
Violations
${stats.critical_violations || 0}
Critical
${stats.parties_identified || 0}
Parties
`; // Case Info html += `

Case Information

`; html += `

Case Number: ${escHtml(report.case?.case_number || 'N/A')}

`; html += `

Officer: ${escHtml(report.case?.officer_name || 'N/A')}

`; html += `

Location: ${escHtml(report.case?.location || 'N/A')}

`; html += `

Date: ${report.case?.incident_date || 'N/A'}

`; if (report.case?.notes) html += `

Notes: ${escHtml(report.case.notes)}

`; // Scene Summary if (report.scene_summary) { html += `

Scene Analysis

`; html += `

${escHtml(report.scene_summary)}

`; } // Parties if (report.parties?.length) { html += `

Parties Involved

`; report.parties.forEach(p => { html += `

${escHtml(p.label)}

`; html += `

Type: ${p.vehicle_type || 'Unknown'} • Color: ${p.vehicle_color || 'Unknown'}

`; }); } // Violations if (report.violations?.list?.length) { html += `

Traffic Violations Detected

`; report.violations.list.forEach(v => { html += `
${escHtml(v.title)} ${v.severity}

Party: ${escHtml(v.party)} • Confidence: ${Math.round(v.confidence * 100)}%

${v.evidence ? `

${escHtml(v.evidence)}

` : ''}
`; }); } // Fault Analysis if (report.fault_analysis?.determined) { html += `

Fault Analysis

`; html += `

Primary Fault: ${escHtml(report.fault_analysis.primary_fault_party || 'Undetermined')}

`; if (report.fault_analysis.fault_distribution) { html += `

Distribution: `; html += Object.entries(report.fault_analysis.fault_distribution) .map(([k, v]) => `${k}: ${v}%`).join(' • '); html += `

`; } html += `

Confidence: ${Math.round((report.fault_analysis.overall_confidence || 0) * 100)}%

`; if (report.fault_analysis.probable_cause) { html += `

Probable Cause

`; html += `

${escHtml(report.fault_analysis.probable_cause)}

`; } } content.innerHTML = html; } // ── Rules ───────────────────────────────────────────────────────────── async function loadRules() { try { const resp = await fetch(`${API_BASE}/rules`); const data = await resp.json(); renderRules(data); } catch (e) { showToast('Failed to load rules', 'error'); } } function renderRules(data) { const content = document.getElementById('rules-content'); if (!data.categories?.length) { content.innerHTML = '

No rules loaded.

'; return; } content.innerHTML = data.categories.map(cat => `
${escHtml(cat.name)} ${cat.rule_count} rules
${cat.rules.map(r => `
${r.id} ${escHtml(r.title)} ${r.severity}
`).join('')}
`).join(''); } // ── Toast ───────────────────────────────────────────────────────────── function showToast(message, type = 'info') { const container = document.getElementById('toast-container'); const toast = document.createElement('div'); toast.className = `toast ${type}`; toast.textContent = message; container.appendChild(toast); setTimeout(() => toast.remove(), 4000); } // ── Helpers ─────────────────────────────────────────────────────────── function escHtml(str) { if (!str) return ''; const div = document.createElement('div'); div.textContent = str; return div.innerHTML; } function formatDate(dateStr) { if (!dateStr) return ''; try { const d = new Date(dateStr); return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); } catch { return dateStr; } }