-
-
Field Operations
-
Crowdsourced Verification.
-
- Mobile integration allows authorized field agents and citizens to upload visual data. Computer vision models validate damage and adjust risk scores instantly.
-
-
-
-
-
- Report Incident
-
-
- * Geo-location required for accurate tagging.
-
-
-
-
-
-
-
-
-
SCANNING_SURFACE...
-
-
-
DAMAGE TYPE CONCRETE FISSURE
-
SEVERITY CRITICAL
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Deployment Access
-
-
-
-
PILOT
-
Proof of Concept
-
Single district deployment for evaluation.
-
- > 50 Assets
- > 3 Admin Keys
- > Basic Logs
-
-
Request Key
-
-
-
RECOMMENDED
-
MUNICIPAL
-
Standard License
-
Full city-wide monitoring suite.
-
- > Unlimited Assets
- > API Access
- > Historical Data
-
-
Request Access
-
-
-
STATE
-
Custom Build
-
On-premise solutions for government bodies.
-
- > Dedicated Server
- > Custom AI Models
- > SLA Support
-
-
Contact Sales
-
-
-
-
-
-
-
-
-
-
-
-
-
Our Mission
-
Immunizing Cities Against Decay.
-
-
- Infrastructure is the silent backbone of civilization. We believe that concrete shouldn't just stand; it should speak.
-
-
- StructIQ was founded by a coalition of civil engineers and machine learning specialists with a singular directive: Move from reactive repairs to predictive prevention.
-
-
- By fusing satellite imagery, IoT sensor data, and historical blueprints, we are building the nervous system for the modern metropolis.
-
-
-
-
-
-
-
-
-
-
-
-
-
- Support
-
Common Queries
-
-
-
-
- How does the prediction engine work?
-
- Our AI analyzes historical data (age, material), real-time sensor inputs (load, vibration), and environmental factors to calculate a dynamic probability of failure.
-
-
-
-
- Is it compatible with existing municipal systems?
-
- Yes. StructIQ is built with a RESTful API architecture that allows seamless data export to existing GIS and asset management software used by city councils.
-
-
-
-
- What is included in the Pilot Program?
-
- The Pilot allows you to monitor up to 50 assets in a single district for 3 months. It includes full access to the dashboard and basic reporting features.
-
-
-
-
- How secure is the infrastructure data?
-
- We use enterprise-grade encryption for all data at rest and in transit. Access is strictly controlled via role-based authentication suitable for government standards.
-
-
-
-
-
-
-
-
-
-
-
-
-
×
-
-
⚠ NEW INCIDENT REPORT
-
SECURE AI UPLOAD CHANNEL
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
+
+
+
+
+
+
+
+
StructIQ — Infrastructure, understood.
+
+
+
+
+
+
+
+
Skip to content
+
StructIQ Opening your workspace…
+
+
×
+
JavaScript is required for the dashboard. You can still read the API documentation or project source .
+
+
diff --git a/main.py b/main.py
index 827345a..e11f55c 100644
--- a/main.py
+++ b/main.py
@@ -1,202 +1,684 @@
-import random
-from fastapi import FastAPI, Depends, HTTPException, File, UploadFile, Form, Query
-from sqlalchemy.orm import Session
-from fastapi.middleware.cors import CORSMiddleware
-import database # Assumes database.py contains Asset and Report models
-from schemas import AssetCreate
-from scoring import calculate_health, maintenance_priority
-
-# 1. INITIALIZE APP
-app = FastAPI(title="StructIQ AI Engine : Chennai")
-
-# 2. CORS CONFIGURATION
-# Allows your HTML files to talk to this Python server
-app.add_middleware(
- CORSMiddleware,
- allow_origins=["*"],
- allow_credentials=False,
- allow_methods=["*"],
- allow_headers=["*"],
-)
-
-# 3. DATABASE INITIALIZATION
-database.Base.metadata.create_all(bind=database.engine)
-
-def get_db():
- db = database.SessionLocal()
- try:
- yield db
- finally:
- db.close()
-
-# 5. API ENDPOINTS
-
-@app.get("/assets")
-def get_assets(db: Session = Depends(get_db)):
- """Fetches all assets for the Map and Sidebar."""
- return db.query(database.Asset).all()
-
-@app.get("/reports")
-def get_reports(status: str | None = Query(None, pattern="^(Open|Resolved)$"), db: Session = Depends(get_db)):
- """Fetches all citizen reports for the Incident Feed."""
- query = db.query(database.Report)
- if status is not None:
- query = query.filter(database.Report.status == status)
- return query.order_by(database.Report.id.desc()).all()
-
-@app.post("/assets")
-def create_asset(asset: AssetCreate, db: Session = Depends(get_db)):
- """Manually adds a new asset with age-calculated health."""
- try:
- calculated_age, final_score, prio = calculate_health(
- asset.asset_type,
- asset.construction_year,
- )
- except ValueError as error:
- raise HTTPException(status_code=422, detail=str(error)) from error
-
- new_asset = database.Asset(
- name=asset.name,
- asset_type=asset.asset_type,
- age=calculated_age,
- construction_year=asset.construction_year,
- latitude=asset.latitude,
- longitude=asset.longitude,
- health_score=float(final_score),
- maintenance_priority=prio,
- vibration_level=round(random.uniform(0.01, 0.08), 3)
- )
- db.add(new_asset)
- db.commit()
- db.refresh(new_asset)
- return new_asset
-
-@app.post("/reports/upload-ai")
-async def upload_ai_report(
- asset_id: int = Form(...),
- description: str = Form(..., min_length=1, max_length=2000),
- file: UploadFile = File(...),
- db: Session = Depends(get_db)
-):
- """Simulates image-based crack triage for the prototype workflow."""
- description = description.strip()
- if not description:
- raise HTTPException(status_code=422, detail="Description must not be blank.")
- if file.content_type not in {"image/jpeg", "image/png", "image/webp"}:
- raise HTTPException(status_code=415, detail="Use a JPEG, PNG, or WebP image.")
- content = await file.read(5 * 1024 * 1024 + 1)
- await file.close()
- if not content:
- raise HTTPException(status_code=422, detail="Image must not be empty.")
- if len(content) > 5 * 1024 * 1024:
- raise HTTPException(status_code=413, detail="Image exceeds the 5 MB limit.")
-
- asset = db.query(database.Asset).filter(database.Asset.id == asset_id).first()
- if not asset: raise HTTPException(status_code=404, detail="Asset ID not found.")
-
- # Prototype-only simulation. Replace with a validated model before real use.
- ai_severity = random.choice([5, 10, 15])
- labels = {5: "Minor Hairline", 10: "Significant Surface", 15: "Critical Structural"}
- ai_label = labels[ai_severity]
-
- new_report = database.Report(
- asset_id=asset_id,
- description=f"SIMULATED TRIAGE [{ai_label}]: {description}",
- severity=ai_severity,
- status="Open"
- )
-
- # AI Impact: Reduce health based on severity
- asset.health_score = max(0.0, asset.health_score - (ai_severity * 1.5))
- asset.maintenance_priority = maintenance_priority(asset.health_score)
-
- db.add(new_report)
- db.commit()
- db.refresh(new_report)
- return {"report_id": new_report.id, "analysis": ai_label, "severity": ai_severity, "simulated": True}
-
-@app.post("/weather/trigger-flood")
-def trigger_flood_alert(db: Session = Depends(get_db)):
- """Simulates monsoon impact on Chennai Roads."""
- roads = db.query(database.Asset).filter(database.Asset.asset_type == "Road").all()
- for road in roads:
- road.health_score = max(0.0, road.health_score - 15.0)
- road.maintenance_priority = maintenance_priority(road.health_score)
- db.commit()
- return {"status": "FLOOD ALERT ACTIVE", "affected_count": len(roads)}
-
-@app.post("/reports/{report_id}/resolve")
-def resolve_report(report_id: int, db: Session = Depends(get_db)):
- """Retain the report and restore health only on its first resolution."""
- report = db.query(database.Report).filter(database.Report.id == report_id).first()
- if not report: raise HTTPException(status_code=404)
-
- if report.status == "Resolved":
- return {"status": "success", "report_id": report.id, "already_resolved": True}
-
- asset = db.query(database.Asset).filter(database.Asset.id == report.asset_id).first()
- if asset:
- asset.health_score = min(100, asset.health_score + (report.severity * 1.5))
- asset.maintenance_priority = maintenance_priority(asset.health_score)
-
- report.status = "Resolved"
- db.commit()
- return {"status": "success", "report_id": report.id, "already_resolved": False}
-
-@app.post("/assets/{asset_id}/maintenance")
-def perform_maintenance(asset_id: int, db: Session = Depends(get_db)):
- """FEATURE: Admin manually boosts health after repair."""
- asset = db.query(database.Asset).filter(database.Asset.id == asset_id).first()
- if not asset: raise HTTPException(status_code=404)
-
- asset.health_score = min(100.0, asset.health_score + 20.0)
- asset.maintenance_priority = maintenance_priority(asset.health_score)
-
- db.commit()
- return {"new_health": asset.health_score, "status": "Maintenance logged"}
-
-# --- JUDGES DEMO SETUP ROUTE ---
-@app.post("/setup-demo")
-def setup_demo(db: Session = Depends(get_db)):
- """Seeds the database with 9 realistic Chennai landmarks."""
- demo_assets = [
- {"name": "Adyar Bridge", "asset_type": "Bridge", "latitude": 13.0067, "longitude": 80.2595, "construction_year": 1970},
- {"name": "Napier Bridge", "asset_type": "Bridge", "latitude": 13.0694, "longitude": 80.2824, "construction_year": 1869},
- {"name": "Ennore Creek Bridge", "asset_type": "Bridge", "latitude": 13.2217, "longitude": 80.3222, "construction_year": 2005},
- {"name": "Anna Flyover", "asset_type": "Flyover", "latitude": 13.0500, "longitude": 80.2500, "construction_year": 1973},
- {"name": "Kathipara Cloverleaf", "asset_type": "Flyover", "latitude": 13.0067, "longitude": 80.2050, "construction_year": 2008},
- {"name": "T.Nagar Skywalk", "asset_type": "Flyover", "latitude": 13.0333, "longitude": 80.2333, "construction_year": 2022},
- {"name": "OMR IT Expressway", "asset_type": "Road", "latitude": 12.9228, "longitude": 80.2316, "construction_year": 2006},
- {"name": "ECR Highway", "asset_type": "Road", "latitude": 12.8491, "longitude": 80.2433, "construction_year": 1998},
- {"name": "Mount Road", "asset_type": "Road", "latitude": 13.0600, "longitude": 80.2500, "construction_year": 1950}
- ]
-
- for data in demo_assets:
- existing = db.query(database.Asset).filter(database.Asset.name == data["name"]).first()
- if not existing:
- age, h_score, prio = calculate_health(
- data["asset_type"],
- data["construction_year"],
- )
-
- demo_floor = 15.0 if data["asset_type"] == "Road" else 20.0
- h_score = max(demo_floor, h_score)
- prio = maintenance_priority(h_score)
-
- # Demo exception: Napier is old but represented as well maintained.
- if data["name"] == "Napier Bridge":
- h_score = 92.5
- prio = maintenance_priority(h_score)
-
- new_asset = database.Asset(
- **data, age=age, health_score=round(h_score, 1),
- maintenance_priority=prio, vibration_level=0.02
- )
- db.add(new_asset)
-
- db.commit()
- return {"status": "Demo assets loaded successfully", "count": 9}
-
-if __name__ == "__main__":
- import uvicorn
- uvicorn.run("main:app", host="127.0.0.1", port=8000, reload=True)
+"""StructIQ: an infrastructure maintenance workflow demonstration."""
+
+import csv
+import io
+import os
+import secrets
+import warnings
+from contextlib import asynccontextmanager
+from datetime import timedelta
+from pathlib import Path
+from typing import Literal
+
+from fastapi import Depends, FastAPI, File, Form, HTTPException, Query, Request, UploadFile
+from fastapi.responses import FileResponse, JSONResponse, RedirectResponse, Response
+from fastapi.staticfiles import StaticFiles
+from PIL import Image, ImageOps, UnidentifiedImageError
+from sqlalchemy import func, text
+from sqlalchemy.exc import IntegrityError
+from sqlalchemy.orm import Session
+
+import database as dbm
+from schemas import ArchiveState, AssetCreate, Credentials, Note, Registration, Scenario
+from scoring import calculate_health, maintenance_priority
+from security import check_password, hash_password, token_hash
+from seed import seed_workspace
+
+ROOT = Path(__file__).parent
+COOKIE = "structiq_session"
+MAX_UPLOAD = 3 * 1024 * 1024
+Image.MAX_IMAGE_PIXELS = 16_000_000
+DUMMY_PASSWORD = hash_password(secrets.token_urlsafe(20))
+
+
+@asynccontextmanager
+async def lifespan(_app):
+ with dbm.engine.begin() as connection:
+ if connection.dialect.name == "postgresql":
+ connection.execute(text("SELECT pg_advisory_xact_lock(729601341)"))
+ dbm.Base.metadata.create_all(connection)
+ yield
+
+
+app = FastAPI(
+ title="StructIQ",
+ version="1.0.0",
+ lifespan=lifespan,
+ description="Private workspaces for a demonstration maintenance workflow. Scores are illustrative, not engineering assessments.",
+)
+
+
+@app.middleware("http")
+async def protect_requests(request: Request, call_next):
+ if request.method not in {"GET", "HEAD", "OPTIONS"}:
+ if request.headers.get("x-requested-with") != "StructIQ":
+ return JSONResponse(
+ {"detail": "Use the StructIQ application to submit this request."}, status_code=403
+ )
+ length = request.headers.get("content-length")
+ if length and (not length.isdigit() or int(length) > MAX_UPLOAD + 65536):
+ return JSONResponse(
+ {"detail": "Request exceeds the 3 MB upload limit."}, status_code=413
+ )
+ response = await call_next(request)
+ response.headers["X-Content-Type-Options"] = "nosniff"
+ response.headers["Referrer-Policy"] = "same-origin"
+ response.headers["X-Frame-Options"] = "DENY"
+ response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=(self)"
+ if request.url.path.startswith("/api/"):
+ response.headers["Cache-Control"] = "no-store"
+ return response
+
+
+def get_db():
+ with dbm.SessionLocal() as db:
+ yield db
+
+
+def workspace(request: Request, db: Session = Depends(get_db)):
+ token = request.cookies.get(COOKIE, "")
+ session = db.get(dbm.LoginSession, token_hash(token)) if token else None
+ if not session or session.expires_at <= dbm.utcnow():
+ raise HTTPException(401, "Your session has expired. Sign in or start a new demo.")
+ account = db.get(dbm.Workspace, session.workspace_id)
+ if not account or (account.is_demo and account.created_at < dbm.utcnow() - timedelta(days=7)):
+ raise HTTPException(401, "This demo has expired. Start a new workspace.")
+ return account
+
+
+def public_workspace(share_token, db):
+ account = db.query(dbm.Workspace).filter_by(share_token=share_token).first()
+ if not account or (account.is_demo and account.created_at < dbm.utcnow() - timedelta(days=7)):
+ raise HTTPException(404, "This reporting link is unavailable.")
+ return account
+
+
+def limited(request, db, action, maximum, seconds=3600):
+ """Atomic persistent limits; production headers are set by the hosting proxy."""
+ address = request.client.host if request.client else "unknown"
+ if os.getenv("VERCEL"):
+ address = request.headers.get("x-vercel-forwarded-for", address).split(",")[0].strip()
+ now = dbm.utcnow()
+ bucket = int(now.timestamp()) // seconds
+ key = f"{action}:{token_hash(address)[:32]}:{bucket}"
+ insert_module = __import__(f"sqlalchemy.dialects.{db.bind.dialect.name}", fromlist=["insert"])
+ statement = insert_module.insert(dbm.RequestLimit).values(
+ key=key, count=1, expires_at=now + timedelta(seconds=seconds * 2)
+ )
+ statement = statement.on_conflict_do_update(
+ index_elements=["key"], set_={"count": dbm.RequestLimit.count + 1}
+ ).returning(dbm.RequestLimit.count)
+ count = db.execute(statement).scalar_one()
+ db.query(dbm.RequestLimit).filter(dbm.RequestLimit.expires_at < now).delete()
+ db.commit()
+ if count > maximum:
+ raise HTTPException(
+ 429, "Too many requests. Please try again later.", headers={"Retry-After": str(seconds)}
+ )
+
+
+def account_data(account):
+ return {
+ "name": account.name,
+ "username": None if account.is_demo else account.username,
+ "is_demo": account.is_demo,
+ "share_token": account.share_token,
+ "flood_active": account.flood_active,
+ }
+
+
+def start_session(db, account):
+ token = secrets.token_urlsafe(32)
+ days = 7 if account.is_demo else 30
+ db.add(
+ dbm.LoginSession(
+ token_hash=token_hash(token),
+ workspace_id=account.id,
+ expires_at=dbm.utcnow() + timedelta(days=days),
+ )
+ )
+ db.commit()
+ response = JSONResponse(account_data(account))
+ response.set_cookie(
+ COOKIE,
+ token,
+ max_age=days * 86400,
+ httponly=True,
+ secure=bool(os.getenv("VERCEL")),
+ samesite="lax",
+ path="/",
+ )
+ return response
+
+
+def activity(db, account, kind, message, asset=None):
+ db.flush()
+ db.add(
+ dbm.Activity(
+ workspace_id=account.id,
+ kind=kind,
+ message=message,
+ asset_id=asset.id if asset else None,
+ health_score=asset_score(db, account, asset) if asset else None,
+ )
+ )
+
+
+def asset_score(db, account, asset):
+ severity = (
+ db.query(func.coalesce(func.sum(dbm.Report.severity), 0))
+ .filter_by(workspace_id=account.id, asset_id=asset.id, status="Open")
+ .scalar()
+ )
+ flood = 15 if account.flood_active and asset.asset_type == "Road" else 0
+ return round(max(0.0, asset.condition_score - severity * 1.5 - flood), 1)
+
+
+def owned_asset(db, account, asset_id, include_archived=False):
+ asset = (
+ db.query(dbm.Asset)
+ .filter_by(id=asset_id, workspace_id=account.id)
+ .with_for_update()
+ .first()
+ )
+ if not asset or (asset.archived and not include_archived):
+ raise HTTPException(404, "Asset not found.")
+ return asset
+
+
+def asset_data(db, account, asset):
+ score = asset_score(db, account, asset)
+ return {
+ "id": asset.id,
+ "name": asset.name,
+ "asset_type": asset.asset_type,
+ "construction_year": asset.construction_year,
+ "age": dbm.utcnow().year - asset.construction_year,
+ "latitude": asset.latitude,
+ "longitude": asset.longitude,
+ "health_score": score,
+ "condition_score": round(asset.condition_score, 1),
+ "maintenance_priority": maintenance_priority(score),
+ "archived": asset.archived,
+ "last_service_at": iso(asset.last_service_at),
+ "created_at": iso(asset.created_at),
+ }
+
+
+def iso(value):
+ return value.isoformat() + "Z" if value else None
+
+
+def report_data(db, report):
+ asset = db.get(dbm.Asset, report.asset_id)
+ return {
+ "id": report.id,
+ "asset_id": report.asset_id,
+ "asset_name": asset.name if asset else "Archived asset",
+ "description": report.description,
+ "severity": report.severity,
+ "status": report.status,
+ "tracking_code": report.tracking_code,
+ "has_image": bool(report.image),
+ "resolution_note": report.resolution_note,
+ "created_at": iso(report.created_at),
+ "resolved_at": iso(report.resolved_at),
+ }
+
+
+@app.get("/api/health")
+def health(db: Session = Depends(get_db)):
+ db.execute(text("SELECT 1"))
+ return {
+ "status": "ok",
+ "database": "postgresql" if db.bind.dialect.name == "postgresql" else "sqlite",
+ "version": "1.0.0",
+ }
+
+
+@app.post("/api/auth/register")
+def register(payload: Registration, request: Request, db: Session = Depends(get_db)):
+ limited(request, db, "register", 5)
+ account = dbm.Workspace(
+ username=payload.username,
+ name=payload.name,
+ password_hash=hash_password(payload.password),
+ share_token=secrets.token_urlsafe(24),
+ )
+ db.add(account)
+ try:
+ db.flush()
+ except IntegrityError:
+ db.rollback()
+ raise HTTPException(
+ 409, "That username is already in use. Choose another or sign in."
+ ) from None
+ seed_workspace(db, account)
+ activity(db, account, "workspace", "Workspace created with nine illustrative Chennai assets.")
+ return start_session(db, account)
+
+
+@app.post("/api/auth/demo")
+def demo(request: Request, db: Session = Depends(get_db)):
+ limited(request, db, "demo", 8)
+ # Demo retention is shown before creation. Registered workspaces are never removed here.
+ db.query(dbm.Workspace).filter(
+ dbm.Workspace.is_demo.is_(True), dbm.Workspace.created_at < dbm.utcnow() - timedelta(days=7)
+ ).delete()
+ account = dbm.Workspace(
+ username="demo_" + secrets.token_hex(10),
+ name="Chennai demo workspace",
+ is_demo=True,
+ share_token=secrets.token_urlsafe(24),
+ )
+ db.add(account)
+ db.flush()
+ seed_workspace(db, account)
+ activity(db, account, "workspace", "Private demo created. All scores are illustrative.")
+ return start_session(db, account)
+
+
+@app.post("/api/auth/login")
+def login(payload: Credentials, request: Request, db: Session = Depends(get_db)):
+ limited(request, db, "login", 15, 900)
+ account = db.query(dbm.Workspace).filter_by(username=payload.username, is_demo=False).first()
+ valid = check_password(payload.password, account.password_hash if account else DUMMY_PASSWORD)
+ if not account or not valid:
+ raise HTTPException(401, "Username or password is incorrect.")
+ return start_session(db, account)
+
+
+@app.get("/api/auth/me")
+def current_account(account=Depends(workspace)):
+ return account_data(account)
+
+
+@app.post("/api/auth/logout")
+def logout(request: Request, db: Session = Depends(get_db)):
+ token = request.cookies.get(COOKIE, "")
+ db.query(dbm.LoginSession).filter_by(token_hash=token_hash(token)).delete()
+ db.commit()
+ response = JSONResponse({"status": "signed_out"})
+ response.delete_cookie(COOKIE, path="/")
+ return response
+
+
+@app.get("/assets", include_in_schema=False)
+@app.get("/api/assets")
+def assets(archived: bool = False, account=Depends(workspace), db: Session = Depends(get_db)):
+ return [
+ asset_data(db, account, a)
+ for a in db.query(dbm.Asset)
+ .filter_by(workspace_id=account.id, archived=archived)
+ .order_by(dbm.Asset.id)
+ ]
+
+
+@app.post("/assets", include_in_schema=False)
+@app.post("/api/assets", status_code=201)
+def create_asset(payload: AssetCreate, account=Depends(workspace), db: Session = Depends(get_db)):
+ if db.query(dbm.Asset).filter_by(workspace_id=account.id).count() >= 100:
+ raise HTTPException(409, "This workspace has reached its 100-asset limit.")
+ _, score, _ = calculate_health(payload.asset_type, payload.construction_year)
+ asset = dbm.Asset(**payload.model_dump(), workspace_id=account.id, condition_score=score)
+ db.add(asset)
+ activity(db, account, "asset", f"Registered {asset.name}.", asset)
+ db.commit()
+ return asset_data(db, account, asset)
+
+
+@app.put("/api/assets/{asset_id}")
+def update_asset(
+ asset_id: int, payload: AssetCreate, account=Depends(workspace), db: Session = Depends(get_db)
+):
+ asset = owned_asset(db, account, asset_id)
+ if (payload.asset_type, payload.construction_year) != (
+ asset.asset_type,
+ asset.construction_year,
+ ):
+ _, asset.condition_score, _ = calculate_health(
+ payload.asset_type, payload.construction_year
+ )
+ for key, value in payload.model_dump().items():
+ setattr(asset, key, value)
+ activity(db, account, "asset", f"Updated registration details for {asset.name}.", asset)
+ db.commit()
+ return asset_data(db, account, asset)
+
+
+@app.patch("/api/assets/{asset_id}/archive")
+def archive_asset(
+ asset_id: int, payload: ArchiveState, account=Depends(workspace), db: Session = Depends(get_db)
+):
+ asset = owned_asset(db, account, asset_id, include_archived=True)
+ if (
+ payload.archived
+ and db.query(dbm.Report).filter_by(asset_id=asset.id, status="Open").count()
+ ):
+ raise HTTPException(409, "Resolve this asset’s open reports before archiving it.")
+ if asset.archived != payload.archived:
+ asset.archived = payload.archived
+ activity(
+ db,
+ account,
+ "asset",
+ f"{'Archived' if payload.archived else 'Restored'} {asset.name}.",
+ asset,
+ )
+ db.commit()
+ return {"archived": asset.archived}
+
+
+@app.post("/assets/{asset_id}/maintenance", include_in_schema=False)
+@app.post("/api/assets/{asset_id}/maintenance")
+def maintain_asset(
+ asset_id: int, payload: Note, account=Depends(workspace), db: Session = Depends(get_db)
+):
+ asset = owned_asset(db, account, asset_id)
+ asset.condition_score = min(100.0, asset.condition_score + 20.0)
+ asset.last_service_at = dbm.utcnow()
+ activity(db, account, "maintenance", f"Maintenance on {asset.name}: {payload.note}", asset)
+ db.commit()
+ return asset_data(db, account, asset)
+
+
+@app.get("/api/activity")
+def events(account=Depends(workspace), db: Session = Depends(get_db)):
+ rows = (
+ db.query(dbm.Activity)
+ .filter_by(workspace_id=account.id)
+ .order_by(dbm.Activity.id.desc())
+ .limit(100)
+ )
+ return [
+ {
+ "id": a.id,
+ "kind": a.kind,
+ "message": a.message,
+ "asset_id": a.asset_id,
+ "health_score": a.health_score,
+ "created_at": iso(a.created_at),
+ }
+ for a in rows
+ ]
+
+
+@app.get("/reports", include_in_schema=False)
+@app.get("/api/reports")
+def reports(
+ status: str | None = Query(None, pattern="^(Open|Resolved)$"),
+ account=Depends(workspace),
+ db: Session = Depends(get_db),
+):
+ query = db.query(dbm.Report).filter_by(workspace_id=account.id)
+ if status:
+ query = query.filter_by(status=status)
+ return [report_data(db, r) for r in query.order_by(dbm.Report.id.desc())]
+
+
+async def verified_image(file):
+ if not file or not file.filename:
+ return None
+ try:
+ if file.content_type not in {"image/jpeg", "image/png", "image/webp"}:
+ raise HTTPException(415, "Attach a JPEG, PNG, or WebP image.")
+ content = await file.read(MAX_UPLOAD + 1)
+ if not content:
+ raise HTTPException(422, "The image is empty.")
+ if len(content) > MAX_UPLOAD:
+ raise HTTPException(413, "Image exceeds the 3 MB limit.")
+ with warnings.catch_warnings():
+ warnings.simplefilter("error", Image.DecompressionBombWarning)
+ with Image.open(io.BytesIO(content)) as source:
+ if source.format not in {"JPEG", "PNG", "WEBP"}:
+ raise HTTPException(415, "The file is not a supported image.")
+ source.verify()
+ with Image.open(io.BytesIO(content)) as source:
+ image = ImageOps.exif_transpose(source).convert("RGB")
+ image.thumbnail((1400, 1400))
+ output = io.BytesIO()
+ image.save(output, "JPEG", quality=75, optimize=True)
+ result = output.getvalue()
+ if len(result) > 600_000:
+ raise HTTPException(413, "Please choose a smaller image.")
+ return result
+ except (
+ UnidentifiedImageError,
+ OSError,
+ ValueError,
+ Image.DecompressionBombError,
+ Image.DecompressionBombWarning,
+ ):
+ raise HTTPException(422, "The attachment could not be verified as a valid image.") from None
+ finally:
+ await file.close()
+
+
+async def save_report(account, db, request, asset_id, description, severity, file):
+ limited(request, db, "report", 20)
+ asset = owned_asset(db, account, asset_id)
+ description = description.strip()
+ if not description:
+ raise HTTPException(422, "Describe the issue before submitting.")
+ cap = 50 if account.is_demo else 200
+ if db.query(dbm.Report).filter_by(workspace_id=account.id).count() >= cap:
+ raise HTTPException(409, f"This workspace has reached its {cap}-report limit.")
+ image = await verified_image(file)
+ report = dbm.Report(
+ workspace_id=account.id,
+ asset_id=asset.id,
+ description=description,
+ severity=int(severity),
+ tracking_code=secrets.token_urlsafe(16),
+ image=image,
+ )
+ db.add(report)
+ activity(
+ db,
+ account,
+ "report",
+ f"New { {5: 'low', 10: 'medium', 15: 'high'}[int(severity)] }-priority report for {asset.name}.",
+ asset,
+ )
+ db.commit()
+ return report_data(db, report)
+
+
+@app.post("/api/reports", status_code=201)
+async def submit_report(
+ request: Request,
+ asset_id: int = Form(...),
+ description: str = Form(..., min_length=1, max_length=2000),
+ severity: Literal["5", "10", "15"] = Form(...),
+ file: UploadFile | None = File(None),
+ account=Depends(workspace),
+ db: Session = Depends(get_db),
+):
+ return await save_report(account, db, request, asset_id, description, severity, file)
+
+
+@app.post("/reports/{report_id}/resolve", include_in_schema=False)
+@app.post("/api/reports/{report_id}/resolve")
+def resolve_report(
+ report_id: int, payload: Note, account=Depends(workspace), db: Session = Depends(get_db)
+):
+ report = (
+ db.query(dbm.Report)
+ .filter_by(id=report_id, workspace_id=account.id)
+ .with_for_update()
+ .first()
+ )
+ if not report:
+ raise HTTPException(404, "Report not found.")
+ if report.status == "Resolved":
+ return {**report_data(db, report), "already_resolved": True}
+ report.status, report.resolution_note, report.resolved_at = (
+ "Resolved",
+ payload.note,
+ dbm.utcnow(),
+ )
+ asset = owned_asset(db, account, report.asset_id, include_archived=True)
+ activity(
+ db,
+ account,
+ "resolution",
+ f"Resolved report #{report.id} for {asset.name}: {payload.note}",
+ asset,
+ )
+ db.commit()
+ return {**report_data(db, report), "already_resolved": False}
+
+
+@app.get("/api/reports/{report_id}/image")
+def report_image(report_id: int, account=Depends(workspace), db: Session = Depends(get_db)):
+ report = db.query(dbm.Report).filter_by(id=report_id, workspace_id=account.id).first()
+ if not report or not report.image:
+ raise HTTPException(404, "Attachment not found.")
+ return Response(
+ report.image,
+ media_type="image/jpeg",
+ headers={"Content-Disposition": "inline; filename=evidence.jpg"},
+ )
+
+
+@app.post("/api/scenarios/flood")
+def scenario(payload: Scenario, account=Depends(workspace), db: Session = Depends(get_db)):
+ account = db.query(dbm.Workspace).filter_by(id=account.id).with_for_update().one()
+ changed = account.flood_active != payload.active
+ account.flood_active = payload.active
+ if changed:
+ activity(
+ db,
+ account,
+ "scenario",
+ "Flood scenario enabled: road scores carry a 15-point demonstration penalty."
+ if payload.active
+ else "Flood scenario cleared: road penalties removed.",
+ )
+ db.commit()
+ return {"active": account.flood_active, "changed": changed}
+
+
+@app.post("/setup-demo", include_in_schema=False)
+@app.post("/api/setup-demo")
+def seed(account=Depends(workspace), db: Session = Depends(get_db)):
+ # Serialize repeated seed requests within this workspace.
+ account = db.query(dbm.Workspace).filter_by(id=account.id).with_for_update().one()
+ count = seed_workspace(db, account)
+ if count:
+ activity(db, account, "workspace", f"Added {count} missing example assets.")
+ db.commit()
+ return {"added": count}
+
+
+@app.get("/api/public/{share_token}/assets")
+def public_assets(share_token: str, db: Session = Depends(get_db)):
+ account = public_workspace(share_token, db)
+ return {
+ "workspace": account.name,
+ "assets": [
+ {"id": a.id, "name": a.name, "asset_type": a.asset_type}
+ for a in db.query(dbm.Asset).filter_by(workspace_id=account.id, archived=False)
+ ],
+ }
+
+
+@app.post("/api/public/{share_token}/reports", status_code=201)
+async def public_report(
+ share_token: str,
+ request: Request,
+ asset_id: int = Form(...),
+ description: str = Form(..., min_length=1, max_length=2000),
+ severity: Literal["5", "10", "15"] = Form(...),
+ file: UploadFile | None = File(None),
+ db: Session = Depends(get_db),
+):
+ account = public_workspace(share_token, db)
+ report = await save_report(account, db, request, asset_id, description, severity, file)
+ return {key: report[key] for key in ["id", "tracking_code", "status", "created_at"]}
+
+
+@app.get("/api/track/{tracking_code}")
+def track(tracking_code: str, request: Request, db: Session = Depends(get_db)):
+ limited(request, db, "tracking", 60, 60)
+ report = db.query(dbm.Report).filter_by(tracking_code=tracking_code).first()
+ if not report:
+ raise HTTPException(404, "Report not found. Check the tracking link.")
+ owner = db.get(dbm.Workspace, report.workspace_id)
+ if not owner or (owner.is_demo and owner.created_at < dbm.utcnow() - timedelta(days=7)):
+ raise HTTPException(404, "This report has expired.")
+ asset = db.get(dbm.Asset, report.asset_id)
+ return {
+ "id": report.id,
+ "asset_name": asset.name,
+ "status": report.status,
+ "created_at": iso(report.created_at),
+ "resolved_at": iso(report.resolved_at),
+ }
+
+
+@app.get("/api/export")
+def export(account=Depends(workspace), db: Session = Depends(get_db)):
+ stream = io.StringIO()
+ writer = csv.writer(stream)
+ writer.writerow(
+ [
+ "ID",
+ "Asset",
+ "Type",
+ "Construction year",
+ "Latitude",
+ "Longitude",
+ "Demo health score",
+ "Priority",
+ "Last maintenance",
+ ]
+ )
+ for asset in db.query(dbm.Asset).filter_by(workspace_id=account.id, archived=False):
+ data = asset_data(db, account, asset)
+ name = asset.name
+ if name.lstrip().startswith(("=", "+", "-", "@")) or name.startswith(("\t", "\r", "\n")):
+ name = "'" + name
+ writer.writerow(
+ [
+ asset.id,
+ name,
+ asset.asset_type,
+ asset.construction_year,
+ asset.latitude,
+ asset.longitude,
+ data["health_score"],
+ data["maintenance_priority"],
+ data["last_service_at"] or "",
+ ]
+ )
+ return Response(
+ stream.getvalue(),
+ media_type="text/csv",
+ headers={"Content-Disposition": 'attachment; filename="structiq-assets.csv"'},
+ )
+
+
+@app.get("/", include_in_schema=False)
+@app.get("/app", include_in_schema=False)
+@app.get("/report/{share_token}", include_in_schema=False)
+@app.get("/track/{tracking_code}", include_in_schema=False)
+def page():
+ return FileResponse(ROOT / "index.html", headers={"Cache-Control": "no-cache"})
+
+
+@app.get("/auth.html", include_in_schema=False)
+def old_dashboard():
+ return RedirectResponse("/app")
+
+
+@app.get("/home.html", include_in_schema=False)
+@app.get("/index.html", include_in_schema=False)
+def old_home():
+ return RedirectResponse("/")
+
+
+# Vercel serves public files directly; this mount provides the same paths locally.
+app.mount(
+ "/static", StaticFiles(directory=ROOT / "public" / "static", check_dir=False), name="static"
+)
diff --git a/public/static/app.js b/public/static/app.js
new file mode 100644
index 0000000..6ea3576
--- /dev/null
+++ b/public/static/app.js
@@ -0,0 +1,305 @@
+import { priority, color, summary, filterAssets, date, initials, severityName } from './utils.js';
+
+const $ = (selector, parent = document) => parent.querySelector(selector);
+const root = $('#root'), modal = $('#modal'), modalBody = $('#modal-body');
+const state = { account: null, assets: [], reports: [], activity: [], view: 'overview', archived: false, reportFilter: 'Open' };
+let map, toastTimer, refreshing = false;
+const paths = {
+ grid: '
',
+ bridge: '
',
+ road: '
',
+ reports: '
',
+ clock: '
',
+ plus: '
',
+ arrow: '
',
+ search: '
',
+ download: '
',
+ check: '
',
+ heart: '
',
+ warning: '
',
+ logout: '
',
+ share: '
',
+ tool: '
',
+};
+function icon(name) { return `
${paths[name] || paths.grid} `; }
+function el(tag, attrs = {}, children = []) {
+ const element = document.createElement(tag);
+ for (const [key, value] of Object.entries(attrs)) {
+ if (value == null) continue;
+ if (key === 'class') element.className = value;
+ else if (key.startsWith('on')) element.addEventListener(key.slice(2), value);
+ else element.setAttribute(key, value);
+ }
+ for (const child of [children].flat(Infinity)) if (child != null) element.append(child instanceof Node ? child : document.createTextNode(String(child)));
+ return element;
+}
+function symbol(name) { const span = el('span', { class: 'icon' }); span.innerHTML = icon(name); return span; }
+function button(label, action, cls = 'button', name) { return el('button', { class: cls, type: 'button', onclick: action }, [name ? symbol(name) : null, label]); }
+function badge(label, tone = label.toLowerCase()) { return el('span', { class: `badge ${tone}` }, [el('span', { class: 'dot' }), label]); }
+function brand() { const a = el('a', { class: 'brand', href: '/', 'aria-label': 'StructIQ home' }); a.innerHTML = '
Struct
IQ '; return a; }
+function toast(message) { const t = $('#toast'); t.textContent = message; t.hidden = false; clearTimeout(toastTimer); toastTimer = setTimeout(() => { t.hidden = true; }, 5500); }
+async function api(path, options = {}) {
+ const headers = { 'X-Requested-With': 'StructIQ', ...options.headers };
+ if (options.body && !(options.body instanceof FormData)) { headers['Content-Type'] = 'application/json'; options.body = JSON.stringify(options.body); }
+ let response;
+ try { response = await fetch(`/api${path}`, { credentials: 'same-origin', ...options, headers, signal: AbortSignal.timeout(25000) }); }
+ catch { throw new Error('Connection interrupted. Check your connection and try again.'); }
+ const body = await response.json().catch(() => ({}));
+ if (!response.ok) {
+ const message = Array.isArray(body.detail) ? body.detail.map(e => `${e.loc.at(-1)}: ${e.msg}`).join(' · ') : body.detail;
+ const error = new Error(message || 'Something went wrong. Please try again.'); error.status = response.status; throw error;
+ }
+ return body;
+}
+function openDialog(title, content) { $('#modal-title').textContent = title; modalBody.replaceChildren(content); if (!modal.open) modal.showModal(); }
+$('#close-modal').onclick = () => modal.close();
+modal.addEventListener('click', event => { if (event.target === modal) { const r = modal.getBoundingClientRect(); if (event.clientX < r.left || event.clientX > r.right || event.clientY < r.top || event.clientY > r.bottom) modal.close(); } });
+function formError(form, message) { let error = $('.error-message', form); if (!error) { error = el('p', { class: 'error-message', role: 'alert' }); form.prepend(error); } error.textContent = message; error.focus?.(); }
+async function submit(form, task) {
+ const controls = [...form.querySelectorAll('button[type="submit"]')]; controls.forEach(b => b.disabled = true);
+ $('.error-message', form)?.remove();
+ try { await task(); } catch (error) { formError(form, error.message); }
+ finally { controls.forEach(b => b.disabled = false); }
+}
+async function startDemo(event) {
+ const target = event?.currentTarget; if (target) target.disabled = true;
+ try { state.account = await api('/auth/demo', { method: 'POST' }); history.pushState({}, '', '/app'); await loadWorkspace(); }
+ catch (error) { toast(error.message); } finally { if (target) target.disabled = false; }
+}
+function authDialog(mode = 'login') {
+ const register = mode === 'register';
+ const form = el('form', { class: 'form' });
+ form.innerHTML = `${register ? '
Workspace name ' : ''}
+
Username
+
Password
+
${register ? 'Use letters, numbers, hyphens, or underscores for your username. Your workspace starts with nine illustrative assets. Keep your password safe; email recovery is not available.' : 'Sign in to your private workspace. Demo workspaces are accessible from the browser where you created them.'}
+
${register ? 'Create workspace' : 'Sign in'} ${icon('arrow')} `;
+ form.onsubmit = event => { event.preventDefault(); submit(form, async () => {
+ const data = Object.fromEntries(new FormData(form));
+ state.account = await api(`/auth/${register ? 'register' : 'login'}`, { method: 'POST', body: data });
+ modal.close(); history.pushState({}, '', '/app'); await loadWorkspace();
+ }); };
+ const content = el('div', {}, [el('p', { class: 'auth-intro' }, register ? 'A dedicated place to organize assets and follow every report through to resolution.' : 'Welcome back. Your assets and reports are right where you left them.'), form,
+ el('div', { class: 'auth-foot' }, [register ? 'Already have a workspace? ' : 'New to StructIQ? ', button(register ? 'Sign in' : 'Create an account', () => authDialog(register ? 'login' : 'register'), 'text-button')])]);
+ openDialog(register ? 'Create your workspace' : 'Sign in to StructIQ', content);
+}
+function landing() {
+ destroyMap();
+ root.innerHTML = `
+
Infrastructure, understood. Every asset. Every report.A clearer picture. Bring asset health, community reports, and maintenance work into one organized workspace. See what needs attention. Keep track of what happens next.
No account required for a demo · Your own data · Kept for 7 days
+ CHENNAI / EXAMPLE NETWORK Demonstration
+
Anna Flyover Napier Bridge Adyar Bridge
+
09 Example assets
03 Asset categories
01 Connected workflow
From first report to verified closure Full history
+ 01 / UNDERSTAND Your network, in context. Explore bridges, roads, and flyovers on a map. Search the asset register and compare clearly explained demonstration scores.
02 / RESPOND Give every report a path. Capture an issue, attach a photo, and follow its status. Share a reporting link with your community without sharing account access.
03 / REMEMBER Keep the whole story. Record maintenance, resolve incidents with notes, and export your register. A persistent timeline keeps every action in context.
+
`;
+ $('#landing-brand').append(brand()); $('#sign-in').onclick = () => authDialog(); $('#create-account').onclick = () => authDialog('register'); $('#launch-demo').onclick = startDemo;
+ if (state.account) { $('#sign-in').textContent = 'Open workspace'; $('#sign-in').onclick = () => { history.pushState({}, '', '/app'); loadWorkspace(); }; }
+}
+function destroyMap() { if (map) { map.remove(); map = null; } }
+async function loadWorkspace() {
+ if (refreshing) return;
+ refreshing = true;
+ try {
+ const [account, assets, reports, activity] = await Promise.all([api('/auth/me'), api('/assets'), api('/reports'), api('/activity')]);
+ Object.assign(state, { account, assets, reports, activity, archived: false }); renderShell();
+ } catch (error) {
+ if (error.status === 401) { state.account = null; landing(); authDialog(); }
+ else { toast(error.message); if (!$('#content')) root.replaceChildren(el('main', { id: 'main', class: 'public-page' }, [brand(), el('h1', {}, 'Connection unavailable'), el('p', {}, error.message), button('Try again', loadWorkspace)])); }
+ } finally { refreshing = false; }
+}
+function renderShell() {
+ destroyMap(); const open = state.reports.filter(r => r.status === 'Open').length;
+ root.innerHTML = `
`;
+ $('#app-brand').append(brand()); $('.avatar').textContent = initials(state.account.name); $('.account-name').textContent = state.account.name;
+ $('.side-account .small').textContent = state.account.is_demo ? 'Private demo · 7-day retention' : `@${state.account.username}`;
+ $('.top-date').textContent = date(new Date().toISOString()); $('#refresh-data').onclick = loadWorkspace;
+ for (const [key, label, name] of [['overview', 'Overview', 'grid'], ['assets', 'Asset register', 'bridge'], ['reports', 'Incident reports', 'reports'], ['activity', 'Activity log', 'clock']]) {
+ const b = button(label, () => { state.view = key; state.archived = false; renderShell(); }, `nav-item ${state.view === key ? 'active' : ''}`, name);
+ if (state.view === key) b.setAttribute('aria-current', 'page'); if (key === 'reports' && open) b.append(el('span', { class: 'count' }, open)); $('.nav-items').append(b);
+ }
+ $('#logout').onclick = async () => { try { await api('/auth/logout', { method: 'POST' }); state.account = null; history.pushState({}, '', '/'); landing(); } catch (e) { toast(e.message); } };
+ const names = { overview: 'Overview', assets: 'Asset register', reports: 'Incident reports', activity: 'Activity log' }; $('#breadcrumb-view').textContent = names[state.view];
+ ({ overview, assetsView, reportsView, activityView })[{ overview: 'overview', assets: 'assetsView', reports: 'reportsView', activity: 'activityView' }[state.view]]();
+}
+function heading(title, description, actions = []) { return el('div', { class: 'page-head' }, [el('div', {}, [el('h1', {}, title), el('p', {}, description)]), el('div', { class: 'row wrap' }, actions)]); }
+function exportButton() { return el('a', { class: 'button secondary', href: '/api/export', download: 'structiq-assets.csv' }, [symbol('download'), 'Export register']); }
+function panel(title, subtitle, action) { const section = el('section', { class: 'panel' }); section.append(el('div', { class: 'panel-head' }, [el('div', {}, [el('h2', {}, title), subtitle ? el('p', {}, subtitle) : null]), action])); return section; }
+function stats() {
+ const s = summary(state.assets, state.reports), box = el('div', { class: 'stats' });
+ for (const [label, value, foot, name] of [['Monitored assets', String(s.total).padStart(2, '0'), 'Bridges, roads & flyovers', 'bridge'], ['Average demo health', `${s.average.toFixed(1)}%`, 'Illustrative condition estimate', 'heart'], ['Assets needing attention', String(s.attention).padStart(2, '0'), 'Below the 70-point threshold', 'warning'], ['Open reports', String(s.open).padStart(2, '0'), `${state.reports.length - s.open} resolved and retained`, 'reports']]) {
+ box.append(el('article', { class: 'stat' }, [el('div', { class: 'stat-label' }, [label, symbol(name)]), el('div', { class: 'stat-value' }, value), el('div', { class: 'stat-foot' }, foot)]));
+ } return box;
+}
+function overview() {
+ const content = $('#content'); content.append(heading('Network overview', 'A clear view of your assets, reports, and maintenance priorities.', [exportButton(), button('Add asset', () => assetForm(), 'button', 'plus')]), stats());
+ const grid = el('div', { class: 'dashboard-grid' }), mapPanel = panel('Asset geography', 'Chennai example network', badge(`${state.assets.length} assets`, 'neutral'));
+ const wrap = el('div', { class: 'map-wrap' }, [el('div', { class: 'map-fallback' }, 'Map unavailable. All assets remain accessible in the register below.'), el('div', { class: 'map', id: 'asset-map', 'aria-label': 'Map of registered infrastructure assets' })]);
+ mapPanel.append(wrap); const legend = el('div', { class: 'map-legend' });
+ for (const [label, tone] of [['Low priority · 70–100', ''], ['High · 40–69', 'amber'], ['Emergency · under 40', 'red']]) legend.append(el('span', {}, [el('i', { class: `legend-dot ${tone}` }), label])); mapPanel.append(legend);
+ const queue = panel('Incident queue', 'Latest open reports', badge(String(state.reports.filter(r => r.status === 'Open').length), 'neutral'));
+ const reports = state.reports.filter(r => r.status === 'Open').slice(0, 3), list = el('div', { class: 'queue-list' });
+ for (const report of reports) {
+ const item = el('div', { class: 'queue-item' }, [el('div', { class: 'queue-title' }, [button(report.asset_name, () => reportDetail(report), 'text-button'), badge(severityName(report.severity), report.severity === 15 ? 'emergency' : report.severity === 10 ? 'high' : '')]), el('p', {}, report.description), el('div', { class: 'queue-meta' }, [`REPORT #${report.id}`, date(report.created_at, true)])]); list.append(item);
+ }
+ if (!reports.length) list.append(el('div', { class: 'empty' }, [symbol('check'), el('strong', {}, 'All caught up'), 'New reports will appear here. Create one to try the full workflow.', el('div', { style: 'margin-top:15px' }, button('Submit a report', () => reportForm(), 'button secondary slim', 'plus'))]));
+ queue.append(list, el('div', { class: 'queue-footer' }, button('View all reports', () => { state.view = 'reports'; renderShell(); }, 'text-button', 'arrow'))); grid.append(mapPanel, queue); content.append(grid);
+ const tablePanel = panel('Asset register', 'Prioritized by demonstration health score', button('View register', () => { state.view = 'assets'; renderShell(); }, 'text-button', 'arrow'));
+ tablePanel.append(assetTable([...state.assets].sort((a, b) => a.health_score - b.health_score).slice(0, 5))); content.append(tablePanel, scenarioBar()); mountMap();
+}
+function mountMap() {
+ if (!window.L || !$('#asset-map')) return;
+ try {
+ map = L.map('asset-map', { scrollWheelZoom: false, zoomControl: true }).setView([13.025, 80.245], 11);
+ L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19, attribution: '©
OpenStreetMap contributors' }).addTo(map);
+ const points = [];
+ for (const asset of state.assets) {
+ points.push([asset.latitude, asset.longitude]);
+ const popup = el('div', { class: 'map-popup' }, [el('strong', {}, asset.name), el('p', {}, `${asset.asset_type} · ${asset.health_score.toFixed(1)} demo health`), button('Asset details', () => assetDetail(asset), 'text-button')]);
+ L.circleMarker([asset.latitude, asset.longitude], { radius: 7, fillColor: color(asset.health_score), color: '#fff', weight: 2, fillOpacity: 1 }).addTo(map).bindPopup(popup);
+ }
+ if (points.length) map.fitBounds(points, { padding: [38, 38], maxZoom: 13 });
+ $('.map-fallback').hidden = true; requestAnimationFrame(() => map?.invalidateSize());
+ } catch { $('#asset-map').hidden = true; }
+}
+function scoreCell(asset) {
+ const fill = el('span'); fill.style.width = `${Math.max(0, Math.min(100, asset.health_score))}%`; fill.style.background = color(asset.health_score);
+ return el('div', { class: 'score' }, [el('strong', {}, asset.health_score.toFixed(1)), el('div', { class: 'score-bar', 'aria-hidden': 'true' }, fill)]);
+}
+function assetTable(assets) {
+ if (!assets.length) return el('div', { class: 'empty' }, [el('strong', {}, 'No matching assets'), 'Try a different filter or register a new asset.']);
+ const table = el('table'); table.innerHTML = '
Asset name Type Age Demo health Priority Details ';
+ const tbody = el('tbody');
+ for (const a of assets) tbody.append(el('tr', {}, [el('td', {}, el('div', { class: 'asset-name' }, [el('span', { class: 'asset-symbol' }, symbol(a.asset_type === 'Road' ? 'road' : 'bridge')), el('div', {}, [button(a.name, () => assetDetail(a), 'text-button'), el('small', {}, `AST-${String(a.id).padStart(4, '0')}`)])])), el('td', {}, a.asset_type), el('td', { class: 'muted' }, `${a.age} years`), el('td', {}, scoreCell(a)), el('td', {}, badge(priority(a.health_score))), el('td', {}, button(a.archived ? 'Restore' : 'View →', () => a.archived ? toggleArchive(a, false) : assetDetail(a), 'text-button'))]));
+ table.append(tbody); return el('div', { class: 'table-wrap' }, table);
+}
+function scenarioBar() {
+ return el('div', { class: `scenario-bar ${state.account.flood_active ? 'active' : ''}` }, [el('div', {}, [el('strong', {}, state.account.flood_active ? 'Flood scenario is active' : 'Explore a flood scenario'), el('p', {}, 'Applies a reversible 15-point penalty to road scores. This is a demonstration, not live weather.')]), button(state.account.flood_active ? 'Clear scenario' : 'Simulate flood', async event => {
+ event.currentTarget.disabled = true;
+ try { await api('/scenarios/flood', { method: 'POST', body: { active: !state.account.flood_active } }); await loadWorkspace(); toast('Scenario updated.'); } catch (e) { toast(e.message); event.currentTarget.disabled = false; }
+ }, 'button secondary slim')]);
+}
+function assetsView() {
+ const content = $('#content'); content.append(heading('Asset register', 'Search, register, and maintain your infrastructure inventory.', [exportButton(), button('Add asset', () => assetForm(), 'button', 'plus')]));
+ const toolbar = el('div', { class: 'toolbar' }); toolbar.innerHTML = `
${icon('search')} All asset types Bridge Road Flyover All priorities Low High Emergency ${state.archived ? 'Active assets' : 'View archived'}
`;
+ const area = el('div', { class: 'panel' }), input = $('input', toolbar), [type, risk] = toolbar.querySelectorAll('select');
+ let currentAssets = state.assets;
+ const update = () => area.replaceChildren(assetTable(filterAssets(currentAssets, input.value, type.value, risk.value)));
+ [input, type, risk].forEach(control => control.addEventListener(control === input ? 'input' : 'change', update));
+ $('#archive-filter', toolbar).onclick = async () => {
+ try { state.archived = !state.archived; currentAssets = state.archived ? await api('/assets?archived=true') : state.assets; $('#archive-filter').textContent = state.archived ? 'Active assets' : 'View archived'; update(); }
+ catch (e) { toast(e.message); }
+ };
+ content.append(toolbar, area, scenarioBar()); update();
+}
+function assetForm(asset) {
+ const form = el('form', { class: 'form' });
+ form.innerHTML = `
Asset name Asset typeBridge Road Flyover Construction year
Latitude Longitude
Coordinates determine the map position. Changing construction year or asset type recalculates the base demonstration score.
${asset ? 'Save changes' : 'Register asset'} `;
+ if (asset) for (const key of ['name', 'asset_type', 'construction_year', 'latitude', 'longitude']) form.elements[key].value = asset[key];
+ form.onsubmit = event => { event.preventDefault(); submit(form, async () => {
+ const data = Object.fromEntries(new FormData(form)); for (const key of ['construction_year', 'latitude', 'longitude']) data[key] = Number(data[key]);
+ await api(asset ? `/assets/${asset.id}` : '/assets', { method: asset ? 'PUT' : 'POST', body: data }); modal.close(); await loadWorkspace(); toast(asset ? 'Asset updated.' : 'Asset registered.');
+ }); }; openDialog(asset ? 'Edit asset' : 'Register an asset', form);
+}
+function assetDetail(asset) {
+ const incidents = state.reports.filter(r => r.asset_id === asset.id && r.status === 'Open');
+ const body = el('div', {}, [el('div', { class: 'row between' }, [badge(asset.asset_type, 'neutral'), badge(priority(asset.health_score))]),
+ el('div', { class: 'details-grid' }, [el('div', { class: 'detail-stat' }, ['Demo health', el('strong', {}, `${asset.health_score.toFixed(1)} / 100`)]), el('div', { class: 'detail-stat' }, ['Open reports', el('strong', {}, incidents.length)])]),
+ el('div', { class: 'detail-list' }, [el('div', {}, [el('span', {}, 'Construction year'), `${asset.construction_year} (${asset.age} years)`]), el('div', {}, [el('span', {}, 'Coordinates'), `${asset.latitude.toFixed(4)}, ${asset.longitude.toFixed(4)}`]), el('div', {}, [el('span', {}, 'Last maintenance'), date(asset.last_service_at)]), el('div', {}, [el('span', {}, 'Base condition score'), asset.condition_score.toFixed(1)])]),
+ el('p', { class: 'notice' }, 'Score = base condition − open report penalties − an active flood penalty. Reports use the submitter’s priority; photos are evidence for human review, not automated diagnoses.')]);
+ const scores = state.activity.filter(a => a.asset_id === asset.id && a.health_score != null).reverse();
+ if (scores.length > 1) {
+ body.append(el('p', { class: 'progress-heading' }, 'Recorded score history'));
+ const chart = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); chart.setAttribute('viewBox', '0 0 420 105'); chart.setAttribute('class', 'health-chart'); chart.setAttribute('role', 'img'); chart.setAttribute('aria-label', `Recorded demo scores: ${scores.map(s => s.health_score).join(', ')}`);
+ const line = document.createElementNS('http://www.w3.org/2000/svg', 'polyline'); line.setAttribute('points', scores.map((s, i) => `${10 + i * 400 / (scores.length - 1)},${95 - s.health_score * .85}`).join(' ')); line.setAttribute('fill', 'none'); line.setAttribute('stroke', 'currentColor'); line.setAttribute('stroke-width', '2'); chart.append(line); body.append(chart);
+ }
+ body.append(el('div', { class: 'row wrap' }, [button('Log maintenance', () => noteDialog('Log maintenance', `Describe the completed work on ${asset.name}. This raises its base demonstration score by up to 20 points; open report penalties remain until resolved.`, `/assets/${asset.id}/maintenance`), 'button', 'tool'), button('Edit details', () => assetForm(asset), 'button secondary'), button('Report an issue', () => reportForm(asset.id), 'text-button')]));
+ body.append(el('div', { style: 'margin-top:20px' }, button('Archive asset', () => {
+ openDialog('Archive this asset?', el('div', { class: 'stack' }, [el('p', { class: 'auth-intro' }, `${asset.name} will be hidden from the active register. Its history stays available, and you can restore it from the archived view. Open reports must be resolved first.`), button('Archive asset', () => toggleArchive(asset, true), 'button secondary')]));
+ }, 'text-button'))); openDialog(asset.name, body);
+}
+async function toggleArchive(asset, archived) { try { await api(`/assets/${asset.id}/archive`, { method: 'PATCH', body: { archived } }); modal.close(); state.archived = false; await loadWorkspace(); toast(archived ? 'Asset archived. You can restore it from the register.' : 'Asset restored.'); } catch (e) { toast(e.message); } }
+function noteDialog(title, description, endpoint) {
+ const form = el('form', { class: 'form' }); form.append(el('p', { class: 'hint' }, description));
+ const label = el('label', {}, ['Work completed', el('textarea', { name: 'note', required: '', minlength: '5', maxlength: '1000', placeholder: 'Record what was inspected, repaired, or verified.' })]); form.append(label, el('button', { type: 'submit', class: 'button' }, title));
+ form.onsubmit = e => { e.preventDefault(); submit(form, async () => { await api(endpoint, { method: 'POST', body: { note: form.elements.note.value } }); modal.close(); await loadWorkspace(); toast('Work recorded in the activity history.'); }); }; openDialog(title, form);
+}
+function reportForm(assetId, publicContext) {
+ const available = publicContext?.assets || state.assets;
+ const form = el('form', { class: 'form' });
+ form.innerHTML = `
AssetChoose a bridge, road, or flyover What needs attention? Observed priorityLow — routine issue Medium — needs review High — urgent review requested Photo evidence Optional · JPEG, PNG, WebP · max 3 MB Photo metadata is removed before storage. Do not include personal details. This demo does not notify emergency or municipal services.
Submit report ${icon('arrow')} `;
+ for (const asset of available) form.elements.asset_id.append(el('option', { value: asset.id }, asset.name));
+ if (assetId) form.elements.asset_id.value = assetId;
+ form.onsubmit = event => { event.preventDefault(); submit(form, async () => {
+ const data = new FormData(form), file = form.elements.file.files[0];
+ if (!file) data.delete('file'); else if (file.size > 3 * 1024 * 1024) throw new Error('Choose an image smaller than 3 MB.');
+ const report = await api(publicContext ? `/public/${publicContext.token}/reports` : '/reports', { method: 'POST', body: data });
+ if (!publicContext) { modal.close(); await loadWorkspace(); }
+ const result = el('div', { class: 'stack' }, [badge('Report received'), el('p', { class: 'auth-intro' }, 'Your report is saved and awaiting review. Keep this private tracking link to check its status.'), linkBox(`${location.origin}/track/${report.tracking_code}`), el('a', { href: `/track/${report.tracking_code}`, class: 'button secondary', target: '_blank', rel: 'noopener' }, 'View report status')]);
+ if (publicContext) { $('#public-form').replaceChildren(result); } else openDialog('Report submitted', result);
+ }); };
+ if (publicContext) return form;
+ if (!available.length) { toast('Register an asset before submitting a report.'); return; }
+ openDialog('Report an issue', form);
+}
+function reportsView() {
+ const content = $('#content'); content.append(heading('Incident reports', 'Track observations from submission through to documented resolution.', [button('Share reporting link', shareReporting, 'button secondary', 'share'), button('Submit report', () => reportForm(), 'button', 'plus')]));
+ const tabs = el('div', { class: 'tabs', 'aria-label': 'Report status filters' });
+ for (const [value, label] of [['Open', 'Open reports'], ['Resolved', 'Resolved'], ['', 'All history']]) tabs.append(button(label, () => { state.reportFilter = value; renderShell(); }, state.reportFilter === value ? 'active' : ''));
+ content.append(el('div', { class: 'toolbar' }, tabs));
+ const items = state.reports.filter(r => !state.reportFilter || r.status === state.reportFilter), grid = el('div', { class: 'report-grid' });
+ for (const r of items) {
+ const card = el('article', { class: 'report-card' }, [el('div', { class: 'row between' }, [el('span', { class: 'small muted' }, `REPORT #${r.id} · ${date(r.created_at)}`), badge(r.status === 'Resolved' ? 'Resolved' : severityName(r.severity), r.status === 'Resolved' ? '' : r.severity === 15 ? 'emergency' : r.severity === 10 ? 'high' : '')]), el('h3', {}, r.asset_name), el('p', {}, r.description), r.resolution_note ? el('div', { class: 'resolution' }, [el('strong', {}, 'Resolution: '), r.resolution_note]) : null,
+ el('div', { class: 'report-bottom' }, [button(r.has_image ? 'View details & photo' : 'View details', () => reportDetail(r), 'text-button'), r.status === 'Open' ? button('Resolve report', () => resolveDialog(r), 'button secondary slim', 'check') : el('span', { class: 'small muted' }, date(r.resolved_at))])]); grid.append(card);
+ }
+ content.append(items.length ? grid : el('div', { class: 'panel empty' }, [el('strong', {}, 'No reports in this view'), 'Submit an observation to begin the workflow, or choose a different status filter.']));
+}
+function resolveDialog(report) { noteDialog('Resolve report', `Record the resolution of report #${report.id} for ${report.asset_name}. The report and its attachment stay in your history.`, `/reports/${report.id}/resolve`); }
+function reportDetail(report) {
+ const content = el('div', { class: 'stack' }, [el('div', { class: 'row between' }, [badge(report.status), el('span', { class: 'small muted' }, date(report.created_at, true))]), el('strong', {}, report.asset_name), el('p', { class: 'auth-intro', style: 'white-space:pre-wrap;overflow-wrap:anywhere' }, report.description)]);
+ if (report.has_image) content.append(el('img', { class: 'evidence', src: `/api/reports/${report.id}/image`, alt: `Submitted evidence for report ${report.id}` }));
+ if (report.resolution_note) content.append(el('div', { class: 'resolution' }, [el('strong', {}, 'Resolution: '), report.resolution_note]));
+ content.append(el('p', { class: 'small muted' }, `Submitter priority: ${severityName(report.severity)}. Priority is manually selected, not an image diagnosis.`), linkBox(`${location.origin}/track/${report.tracking_code}`));
+ if (report.status === 'Open') content.append(button('Resolve report', () => resolveDialog(report), 'button', 'check')); openDialog(`Report #${report.id}`, content);
+}
+function linkBox(url) {
+ const input = el('input', { value: url, readonly: '', 'aria-label': 'Shareable link' });
+ return el('div', { class: 'link-field' }, [input, button('Copy link', async () => {
+ try { await navigator.clipboard.writeText(url); toast('Link copied.'); } catch { input.select(); toast('Select and copy the link from this field.'); }
+ }, 'button secondary slim')]);
+}
+function shareReporting() {
+ openDialog('Community reporting link', el('div', { class: 'stack' }, [el('p', { class: 'auth-intro' }, 'Anyone with this link can see your active asset names and submit a report. They cannot see your dashboard, report history, or account details.'), linkBox(`${location.origin}/report/${state.account.share_token}`), el('a', { class: 'button secondary', href: `/report/${state.account.share_token}`, target: '_blank', rel: 'noopener' }, 'Open reporting page ↗')]));
+}
+function activityView() {
+ const content = $('#content'); content.append(heading('Activity log', 'A persistent record of registrations, reports, maintenance, and scenarios.', [button('Refresh', loadWorkspace, 'button secondary', 'clock')]));
+ const section = panel('Workspace timeline', 'The 100 most recent events'), list = el('div', { class: 'activity-list' });
+ for (const a of state.activity) list.append(el('article', { class: 'activity-item' }, [el('div', { class: 'activity-icon' }, symbol({ report: 'reports', resolution: 'check', maintenance: 'tool', scenario: 'warning', asset: 'bridge' }[a.kind] || 'grid')), el('div', {}, [el('p', {}, a.message), el('time', { datetime: a.created_at }, date(a.created_at, true))]), a.health_score != null ? badge(`${a.health_score.toFixed(1)} health`, 'neutral') : null]));
+ section.append(state.activity.length ? list : el('div', { class: 'empty' }, 'Workspace events will appear here.')); content.append(section);
+}
+async function publicReportPage(token) {
+ root.replaceChildren(el('main', { class: 'public-page', id: 'main' }, [brand(), el('div', { class: 'panel', id: 'public-form' }, el('p', {}, 'Loading reporting form…'))]));
+ try {
+ const data = await api(`/public/${encodeURIComponent(token)}/assets`), panel = $('#public-form');
+ panel.replaceChildren(el('span', { class: 'eyebrow' }, data.workspace), el('h1', { style: 'margin-top:15px' }, 'Report an observation'), el('p', { class: 'auth-intro' }, 'Share an issue with this workspace. You’ll receive a private tracking link after submission.'), el('div', { class: 'notice' }, 'Portfolio demonstration only. This form does not contact authorities or emergency services.'));
+ panel.append(data.assets.length ? reportForm(null, { token, assets: data.assets }) : el('div', { class: 'empty' }, 'There are no active assets available for reporting.'));
+ } catch (e) { $('#public-form').replaceChildren(el('h1', {}, 'Reporting link unavailable'), el('p', { class: 'auth-intro' }, e.message), el('a', { href: '/', class: 'button secondary' }, 'Back to StructIQ')); }
+}
+async function trackingPage(code) {
+ root.replaceChildren(el('main', { class: 'public-page', id: 'main' }, [brand(), el('section', { class: 'panel', id: 'tracking' }, el('p', {}, 'Loading report status…'))]));
+ try {
+ const r = await api(`/track/${encodeURIComponent(code)}`);
+ $('#tracking').replaceChildren(el('span', { class: 'eyebrow' }, 'Report status'), el('h1', { style: 'margin-top:14px' }, `Report #${r.id}`), el('p', { class: 'auth-intro' }, r.asset_name), badge(r.status),
+ el('div', { class: 'tracking-result' }, [el('p', { class: 'small' }, `Submitted ${date(r.created_at, true)}`), el('p', { class: 'small' }, r.resolved_at ? `Resolved ${date(r.resolved_at, true)}` : 'Your report is saved and awaiting review by the workspace owner.')]), el('p', { class: 'notice' }, 'This is a portfolio demonstration. Report status does not confirm real-world inspection, repair, or safety.'), button('Refresh status', () => trackingPage(code), 'button secondary', 'clock'));
+ } catch (e) { $('#tracking').replaceChildren(el('h1', {}, 'Report unavailable'), el('p', { class: 'auth-intro' }, e.message), el('a', { href: '/', class: 'button secondary' }, 'Back to StructIQ')); }
+}
+async function route() {
+ const path = location.pathname;
+ if (path.startsWith('/report/')) return publicReportPage(path.split('/')[2]);
+ if (path.startsWith('/track/')) return trackingPage(path.split('/')[2]);
+ try { state.account = await api('/auth/me'); } catch { state.account = null; }
+ if (path === '/app' && state.account) return loadWorkspace();
+ landing(); if (path === '/app') authDialog();
+}
+window.addEventListener('popstate', route);
+route();
diff --git a/public/static/favicon.svg b/public/static/favicon.svg
new file mode 100644
index 0000000..dd3d2e3
--- /dev/null
+++ b/public/static/favicon.svg
@@ -0,0 +1 @@
+
diff --git a/public/static/styles.css b/public/static/styles.css
new file mode 100644
index 0000000..010f7a7
--- /dev/null
+++ b/public/static/styles.css
@@ -0,0 +1,11 @@
+@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Manrope:wght@400;500;600;700;800&display=swap');
+:root{--ink:#173136;--muted:#697e81;--paper:#f3f6f4;--card:#fff;--line:#dfe7e2;--green:#227e5b;--mint:#b6efce;--nav:#102c31;--orange:#e6a54c;--red:#b94040;--amber:#a56b16;--shadow:0 12px 40px #102c3108;font-family:'DM Sans',sans-serif;color:var(--ink);background:var(--paper);font-synthesis:none}
+*{box-sizing:border-box}body{margin:0}button,input,select,textarea{font:inherit}button,a,input,select,textarea{outline-offset:4px}button:focus-visible,a:focus-visible{outline:3px solid #3b9e79}button{cursor:pointer}button:disabled{cursor:wait;opacity:.6}a{color:inherit;text-decoration:none}a:hover{text-decoration:underline}h1,h2,h3,h4,p{margin-top:0}h1,h2,h3,.brand{font-family:Manrope,sans-serif}button svg, .icon svg{width:20px;height:20px;flex-shrink:0}svg{vertical-align:middle}button{border:0}input,select,textarea{max-width:100%}[hidden]{display:none!important}.skip-link{position:fixed;left:16px;top:-70px;padding:12px;background:white;z-index:2000}.skip-link:focus{top:10px}.brand{font-size:27px;font-weight:800;letter-spacing:-1.1px;display:inline-flex;align-items:center;gap:9px}.brand>span{color:#65c79b}.brand img{width:35px}.eyebrow{font-size:11px;font-weight:700;letter-spacing:2px;text-transform:uppercase;color:var(--green)}.muted{color:var(--muted)}.small{font-size:12px}.initial{padding:12vh 10vw}.button{padding:12px 19px;display:inline-flex;gap:9px;align-items:center;justify-content:center;border-radius:9px;background:var(--green);color:white;font-size:13px;font-weight:700;min-height:44px;transition:background .15s,transform .15s}.button:hover{background:#176643;text-decoration:none}.button.secondary{background:white;color:var(--ink);border:1px solid var(--line)}.button.secondary:hover{background:#edf4ef}.button.dark{background:var(--nav)}.button.danger{background:var(--red)}.button.slim{padding:8px 13px;min-height:36px;font-size:12px}.button.ghost{background:transparent;color:var(--muted);border:1px solid var(--line)}.icon-button{width:35px;height:35px;display:inline-grid;place-items:center;background:transparent;color:var(--muted);border-radius:7px;font-size:25px}.icon-button:hover{background:#e9f1ed}.row{display:flex;gap:12px;align-items:center}.between{justify-content:space-between}.wrap{flex-wrap:wrap}.stack{display:grid;gap:16px}.notice{padding:12px 16px;border:1px solid #dedfc8;border-radius:9px;background:#f5f5e9;color:#6c7051;font-size:12px;line-height:1.6}.error{background:#fff0ee;color:#9b3831;border-color:#f1d0c9}.error-message{font-size:13px;color:var(--red);line-height:1.5}.empty{padding:38px 22px;text-align:center;color:var(--muted);font-size:13px;line-height:1.7}.empty strong{color:var(--ink);display:block;font-size:16px;margin-bottom:6px}.badge{display:inline-flex;align-items:center;gap:5px;border-radius:5px;padding:5px 8px;background:#edf5ef;color:var(--green);font-size:10px;font-weight:700;white-space:nowrap}.badge.high{background:#fbf1df;color:var(--amber)}.badge.emergency{background:#fbeae7;color:var(--red)}.badge.neutral{color:var(--muted);background:#eff2f0}.dot{width:6px;height:6px;background:currentColor;border-radius:50%;display:inline-block}.demo-label{font-size:10px;letter-spacing:1px;text-transform:uppercase;color:#b8c4b9;border:1px solid #45605c;padding:5px 8px;border-radius:4px}.land-nav{max-width:1240px;margin:auto;padding:25px 35px;display:flex;align-items:center;justify-content:space-between;gap:20px}.land-nav .brand{font-size:24px}.land-nav nav{display:flex;gap:24px;align-items:center;font-size:13px}.landing{background:#f7faf6;min-height:100vh}.hero{max-width:1240px;margin:auto;display:grid;grid-template-columns:1.03fr 1fr;align-items:center;gap:55px;padding:80px 35px 90px}.hero h1{font-size:clamp(43px,4.9vw,67px);letter-spacing:-3.2px;line-height:1.06;font-weight:800;margin:22px 0}.hero h1 em{font-style:normal;color:var(--green)}.hero>div>p{line-height:1.75;color:var(--muted);font-size:16px;max-width:460px}.hero .row{margin:28px 0 16px}.hero-fine{font-size:11px!important;color:#86988c!important}.hero-board{background:var(--nav);border-radius:18px;padding:25px;box-shadow:0 25px 80px #133b3424;color:#effaf3;transform:rotate(1deg);position:relative;overflow:hidden}.hero-board:before{content:'';position:absolute;inset:0;background-image:linear-gradient(#91bdbd0b 1px,transparent 1px),linear-gradient(90deg,#91bdbd0b 1px,transparent 1px);background-size:26px 26px;pointer-events:none}.hero-board>*{position:relative}.hero-board .eyebrow{color:var(--mint)}.hero-map{width:100%;height:235px;margin:15px 0}.preview-metrics{display:grid;grid-template-columns:repeat(3,1fr);border-top:1px solid #46615d;padding-top:20px;gap:15px}.preview-metrics strong{font-size:25px;display:block;font-family:Manrope}.preview-metrics span{font-size:10px;letter-spacing:.7px;text-transform:uppercase;color:#a8c3b9}.mini-incident{margin-top:22px;padding:14px;background:#ffffff0c;border:1px solid #ffffff0d;border-radius:9px;font-size:12px}.mini-incident .badge{background:#fbd29a;color:#735523}.landing-features{max-width:1170px;margin:0 auto;padding:0 0 65px;display:grid;grid-template-columns:repeat(3,1fr);gap:38px}.feature{border-top:1px solid #d8e3d9;padding-top:22px}.feature h3{font-size:16px;margin:15px 0 8px}.feature p{font-size:13px;color:var(--muted);line-height:1.7}.feature-number{font-size:11px;color:var(--green);font-weight:700}.land-bottom{background:#eaf0e8;padding:25px 35px;font-size:12px;color:#5e7265}.land-bottom>div{max-width:1170px;margin:auto;display:flex;gap:25px;justify-content:space-between}.land-bottom p{max-width:670px;margin:0;line-height:1.7}.land-bottom nav{display:flex;gap:20px;align-items:center}
+/* Application */
+.shell{display:grid;grid-template-columns:226px minmax(0,1fr);min-height:100vh}.sidebar{background:var(--nav);color:#bdd0cb;padding:28px 18px 22px;display:flex;flex-direction:column;position:fixed;inset:0 auto 0 0;width:226px;z-index:500}.sidebar .brand{color:#fff;font-size:26px;margin:0 13px 41px}.sidebar .brand img{width:31px}.nav-label{font-size:9px;color:#6f928a;letter-spacing:1.6px;text-transform:uppercase;margin:0 15px 14px}.nav-items{display:grid;gap:7px}.nav-item{padding:13px 14px;border-radius:8px;display:flex;align-items:center;gap:13px;background:transparent;color:#97b4ac;text-align:left;font-size:12px;font-weight:500}.nav-item:hover{background:#ffffff08;color:#fff}.nav-item.active{background:#284941;color:#d1f2dd}.nav-item .count{margin-left:auto;background:#ffffff12;padding:2px 6px;border-radius:4px;font-size:10px}.side-note{margin-top:auto;padding:20px 13px}.side-note .line-art{border-top:1px solid #36534c;padding-top:20px;color:#52766a}.side-note p{font-size:10px;line-height:1.7;color:#7f9d92;margin:12px 0}.side-account{display:flex;gap:10px;align-items:center;padding:16px 10px 0;border-top:1px solid #294d42}.avatar{width:34px;height:34px;border-radius:50%;display:grid;place-items:center;background:#426b56;color:#cef0d7;font-size:11px;font-weight:700;flex-shrink:0}.side-account .account-name{font-size:11px;color:#d8e9df;max-width:125px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.side-account .small{font-size:10px;color:#83a693;margin-top:4px}.side-account button{margin-left:auto;color:#90ac9f}.workspace-main{grid-column:2;min-width:0}.topbar{height:79px;border-bottom:1px solid var(--line);background:#ffffff90;display:flex;align-items:center;justify-content:space-between;padding:0 34px;gap:12px}.breadcrumb{font-size:11px;color:var(--muted);display:flex;gap:11px;align-items:center}.breadcrumb strong{color:var(--ink);font-weight:500}.live-pill{display:inline-flex;gap:6px;align-items:center;font-size:10px;color:var(--green);background:#e7f3eb;padding:7px 11px;border-radius:20px}.top-date{font-size:11px;color:var(--muted)}.content{padding:31px 34px;max-width:1640px;margin:auto}.page-head{display:flex;gap:20px;justify-content:space-between;align-items:center;margin-bottom:25px}.page-head h1{font-size:26px;letter-spacing:-1px;font-weight:800;margin:0 0 8px}.page-head p{font-size:12px;color:var(--muted);margin:0}.stats{display:grid;grid-template-columns:repeat(4,1fr);gap:16px;margin-bottom:23px}.stat{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:20px 21px;box-shadow:var(--shadow)}.stat-label{display:flex;justify-content:space-between;gap:12px;font-size:11px;color:var(--muted)}.stat-label .icon{color:#6d9488}.stat-value{font-family:Manrope;font-size:33px;font-weight:800;letter-spacing:-1px;margin:10px 0 8px;line-height:1}.stat-foot{font-size:10px;color:var(--muted)}.stat-foot .green{color:var(--green)}.panel{background:white;border:1px solid var(--line);border-radius:11px;overflow:hidden;box-shadow:var(--shadow);min-width:0}.panel-head{padding:18px 20px;display:flex;align-items:center;justify-content:space-between;gap:15px;border-bottom:1px solid #edf0ed}.panel-head h2{font-size:13px;font-weight:800;margin:0}.panel-head p{font-size:10px;color:var(--muted);margin:5px 0 0}.dashboard-grid{display:grid;grid-template-columns:minmax(0,1fr) 303px;gap:20px;margin-bottom:22px}.map-wrap{height:356px;position:relative;background:#dfe8df}.map{height:100%;width:100%;z-index:1}.map-fallback{position:absolute;inset:0;display:grid;place-items:center;padding:25px;font-size:13px;color:var(--muted);text-align:center}.map-legend{padding:12px 20px;display:flex;gap:20px;align-items:center;font-size:10px;color:var(--muted);border-top:1px solid var(--line)}.map-legend span{display:flex;align-items:center;gap:6px}.legend-dot{height:7px;width:7px;border-radius:50%;background:var(--green)}.legend-dot.amber{background:#ce9848}.legend-dot.red{background:#bc5751}.map-label{background:white;border:1px solid var(--line);font-family:'DM Sans';font-size:11px;color:var(--ink);border-radius:5px;padding:5px 8px}.map-popup strong{display:block;margin-bottom:6px;font-size:13px}.map-popup p{margin:0 0 9px;color:var(--muted);font-size:11px}.leaflet-control-attribution{font-size:9px!important}.leaflet-control-zoom a{color:var(--ink)!important}.queue-list{padding:0 18px}.queue-item{padding:18px 0;border-bottom:1px solid #edf0ed;cursor:pointer}.queue-item:last-child{border:0}.queue-title{font-size:12px;font-weight:700;display:flex;gap:8px;justify-content:space-between;align-items:center;margin-bottom:8px}.queue-item p{font-size:11px;color:var(--muted);margin:0 0 9px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.queue-meta{font-size:9px;color:#8a9c94;display:flex;justify-content:space-between}.queue-footer{padding:14px 18px;border-top:1px solid var(--line)}.text-button{background:transparent;color:var(--green);font-size:11px;font-weight:700;padding:4px 0;display:inline-flex;gap:7px;align-items:center}.text-button:hover{text-decoration:underline}.table-wrap{overflow:auto}table{width:100%;border-collapse:collapse;text-align:left;white-space:nowrap;font-size:11px}th{background:#f8faf7;color:#82938b;font-size:9px;letter-spacing:.8px;text-transform:uppercase;font-weight:500;padding:12px 20px}td{padding:15px 20px;border-bottom:1px solid #edf0ed}tbody tr:last-child td{border:0}tbody tr:hover{background:#fbfcfa}.asset-name{display:flex;align-items:center;gap:11px}.asset-symbol{display:grid;place-items:center;width:31px;height:31px;background:#f0f5ef;color:#689883;border-radius:7px}.asset-symbol svg{width:17px;height:17px}.asset-name strong{display:block;font-size:11px;font-weight:600}.asset-name small{display:block;font-size:9px;color:#90a195;margin-top:4px}.score{display:flex;gap:9px;align-items:center}.score strong{font-size:11px;min-width:28px}.score-bar{width:63px;height:4px;background:#eaf0e9;overflow:hidden;border-radius:2px}.score-bar span{display:block;height:100%;border-radius:2px}.toolbar{display:flex;justify-content:space-between;gap:14px;align-items:center;margin-bottom:19px}.search-wrap{display:flex;align-items:center;gap:9px;background:white;border:1px solid var(--line);padding:9px 12px;border-radius:8px;min-width:240px;color:#8c9f94}.search-wrap input{border:0;outline:none;width:100%;min-width:0;font-size:12px;color:var(--ink);background:transparent}.filter{background:white;color:var(--muted);border:1px solid var(--line);border-radius:8px;padding:9px 12px;min-height:39px;font-size:11px}.tabs{display:flex;gap:4px;padding:4px;border:1px solid var(--line);background:white;border-radius:9px;width:max-content}.tabs button{padding:8px 16px;border-radius:6px;background:transparent;color:var(--muted);font-size:12px}.tabs button.active{background:#e9f3eb;color:var(--green);font-weight:700}.scenario-bar{margin-top:22px;border:1px dashed #cddccf;border-radius:9px;display:flex;gap:16px;align-items:center;justify-content:space-between;padding:15px 18px;background:#eef3ec}.scenario-bar strong{font-size:11px;display:block;margin-bottom:5px}.scenario-bar p{font-size:10px;color:var(--muted);margin:0;line-height:1.6}.scenario-bar.active{background:#fbf3e5;border-color:#e3cfa9}.app-foot{margin-top:25px;font-size:10px;line-height:1.7;color:#91a196;display:flex;gap:20px;justify-content:space-between}.report-card{padding:22px;background:white;border:1px solid var(--line);border-radius:10px}.report-card h3{font-size:14px;margin:12px 0 8px}.report-card p{font-size:12px;line-height:1.7;color:var(--muted);white-space:pre-wrap;overflow-wrap:anywhere}.report-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}.report-card .report-bottom{border-top:1px solid var(--line);padding-top:13px;margin-top:15px;display:flex;align-items:center;justify-content:space-between;gap:10px}.activity-list{padding:5px 22px}.activity-item{display:grid;grid-template-columns:34px 1fr auto;gap:16px;padding:20px 0;border-bottom:1px solid var(--line)}.activity-item:last-child{border:0}.activity-icon{height:31px;width:31px;border-radius:50%;background:#eaf4eb;color:var(--green);display:grid;place-items:center}.activity-icon svg{width:15px;height:15px}.activity-item p{font-size:12px;line-height:1.65;margin:0;white-space:pre-wrap;overflow-wrap:anywhere}.activity-item time{font-size:10px;color:var(--muted);display:block;margin-top:5px}.activity-item .badge{height:max-content}.details-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:12px;margin:16px 0}.detail-stat{background:#f4f7f2;border-radius:8px;padding:14px;font-size:11px;color:var(--muted)}.detail-stat strong{font-size:21px;color:var(--ink);display:block;margin-top:7px}.detail-list{font-size:12px;display:grid;gap:11px;line-height:1.5;margin:20px 0}.detail-list>div{display:flex;justify-content:space-between;gap:15px}.detail-list span{color:var(--muted)}.health-chart{width:100%;height:100px;color:var(--green);margin:10px 0}.progress-heading{font-size:11px;color:var(--muted);margin-top:20px}.resolution{background:#f0f7ef;border-radius:7px;padding:12px;font-size:12px;margin-top:14px}.public-page{max-width:670px;padding:50px 22px;margin:auto}.public-page>.brand{margin-bottom:32px}.public-page h1{font-size:31px;letter-spacing:-1px}.public-page>.panel{padding:30px}.public-page .notice{margin:20px 0}.public-page .tracking-code{font-family:monospace;font-size:14px;overflow-wrap:anywhere}.link-field{display:flex;gap:8px}.link-field input{flex:1;min-width:0}.tracking-result{padding:20px;background:#f0f6ef;border-radius:10px;margin-top:20px}dialog{border:1px solid var(--line);border-radius:14px;background:#fff;padding:0;width:min(530px,calc(100% - 30px));color:var(--ink);box-shadow:0 30px 120px #102c3155;max-height:90dvh}dialog::backdrop{background:#102c3185;backdrop-filter:blur(3px)}.dialog-head{padding:20px 24px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--line);position:sticky;top:0;background:#fff;z-index:2}.dialog-head h2{font-size:18px;margin:0;letter-spacing:-.4px}#modal-body{padding:24px}.form{display:grid;gap:17px}.form label{font-size:12px;font-weight:600;display:grid;gap:8px}.form input,.form select,.form textarea,.link-field input{border:1px solid #cddacf;border-radius:7px;padding:11px 12px;width:100%;background:white;color:var(--ink);font-size:13px;min-height:43px}.form input:focus,.form select:focus,.form textarea:focus{outline:2px solid #7bb491;border-color:transparent}.form textarea{resize:vertical;min-height:100px}.form .hint{font-size:11px;color:var(--muted);line-height:1.6;margin:0}.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px}.form hr{width:100%;border:0;border-top:1px solid var(--line);margin:3px 0}.form .button{width:100%}.form .form-check{display:flex;align-items:flex-start;gap:9px;font-size:11px;font-weight:400;line-height:1.6}.form-check input{width:16px;min-height:16px;margin-top:2px}.evidence{width:100%;max-height:330px;object-fit:contain;border-radius:8px;background:#f2f5ee}.auth-intro{font-size:13px;color:var(--muted);line-height:1.7}.auth-foot{text-align:center;font-size:12px;margin-top:20px;color:var(--muted)}#toast{position:fixed;bottom:24px;right:24px;max-width:min(460px,calc(100% - 32px));background:var(--nav);color:white;border-radius:10px;padding:16px 21px;box-shadow:0 10px 45px #0002;z-index:3000;font-size:13px;line-height:1.5}.loading-state{padding:60px;text-align:center;font-size:13px;color:var(--muted)}
+@media(min-width:1450px){.dashboard-grid{grid-template-columns:minmax(0,1fr) 360px}.map-wrap{height:395px}.queue-item{padding:22px 0}.content{padding:37px 45px}.topbar{padding:0 45px}}
+@media(max-width:1100px){.shell{grid-template-columns:190px minmax(0,1fr)}.sidebar{width:190px;padding:25px 12px}.sidebar .brand{font-size:24px;margin-left:9px}.content{padding:25px 22px}.topbar{padding:0 22px}.dashboard-grid{grid-template-columns:minmax(0,1fr) 270px;gap:15px}.stat{padding:17px 15px}.stats{gap:12px}.hero{gap:35px}.landing-features{padding-left:35px;padding-right:35px}.hero-board{padding:21px}.page-head{align-items:flex-start}.page-head .row{gap:8px}.page-head .button{font-size:11px;padding:10px 12px}td,th{padding-left:16px;padding-right:16px}}
+@media(max-width:900px){.dashboard-grid{grid-template-columns:1fr}.queue-list{display:grid;grid-template-columns:repeat(3,1fr);gap:18px}.queue-item{border:0}.queue-title{flex-wrap:wrap}.queue-footer{display:none}.hero{padding-top:40px;padding-bottom:55px;grid-template-columns:1fr;max-width:710px;gap:35px}.hero>div>p{max-width:560px}.hero h1{font-size:58px}.hero-board{transform:none}.hero-map{height:200px}.landing-features{gap:25px}.report-grid{grid-template-columns:1fr}.top-date{display:none}.stats{grid-template-columns:repeat(2,1fr)}}
+@media(max-width:700px){.shell{display:block}.sidebar{position:relative;inset:auto;width:100%;padding:16px 16px 0;display:grid;grid-template-columns:1fr auto;gap:15px}.sidebar .brand{margin:0;font-size:23px}.nav-label,.side-note{display:none}.nav-items{grid-column:1/-1;grid-row:2;display:flex;gap:0;justify-content:space-between}.nav-item{font-size:10px;padding:12px 9px;border-radius:6px 6px 0 0;gap:7px}.nav-item svg{width:16px;height:16px}.nav-item .count{display:none}.side-account{padding:0;border:0;gap:7px;grid-column:2;grid-row:1}.side-account .avatar{display:none}.side-account .account-name{max-width:145px;font-size:10px}.side-account .small{font-size:9px}.side-account .icon-button{width:28px}.workspace-main{width:100%}.topbar{height:52px;padding:0 18px}.content{padding:24px 16px}.breadcrumb{font-size:10px}.live-pill{font-size:9px}.page-head{gap:15px;flex-direction:column;margin-bottom:21px}.page-head h1{font-size:25px}.page-head p{font-size:11px;line-height:1.6}.page-head>.row{width:100%}.page-head>.row .button{flex:1}.stats{gap:10px;margin-bottom:17px}.stat{padding:17px}.stat-label{font-size:10px}.stat-value{font-size:31px}.stat-foot{font-size:9px}.dashboard-grid{gap:17px;margin-bottom:17px}.panel-head{padding:16px}.map-wrap{height:310px}.map-legend{padding:12px 14px;gap:12px;font-size:9px}.queue-list{display:block;padding:0 16px}.queue-item{padding:15px 0;border-bottom:1px solid var(--line)}.queue-title{flex-wrap:nowrap}.queue-footer{display:block}.toolbar{align-items:stretch;flex-wrap:wrap;gap:9px}.search-wrap{width:100%;min-width:0}.toolbar>.row{width:100%;gap:8px}.filter{flex:1;min-width:0;padding:9px;font-size:10px}.scenario-bar{align-items:flex-start;gap:13px}.scenario-bar .button{padding:9px 12px;flex-shrink:0}.scenario-bar p{font-size:9px}.app-foot{display:block;font-size:9px}.app-foot a{display:inline-block;margin-top:8px}.activity-item{grid-template-columns:30px 1fr;gap:10px}.activity-item>.badge{grid-column:2;width:max-content}.activity-list{padding:0 16px}.activity-item p{font-size:11px}.report-card{padding:18px}.land-nav{padding:20px}.land-nav nav{gap:15px}.land-nav nav>a:first-child{display:none}.land-nav .button{font-size:11px;padding:10px 13px}.hero{padding:38px 22px 46px;gap:30px}.hero h1{font-size:47px;letter-spacing:-2px}.hero>div>p{font-size:14px}.hero .row{flex-wrap:wrap}.hero .button{flex:1;white-space:nowrap;font-size:12px}.hero-map{height:210px}.hero-board{padding:20px;border-radius:13px}.preview-metrics strong{font-size:23px}.preview-metrics span{font-size:8px}.landing-features{grid-template-columns:1fr;padding:0 25px 35px;gap:18px}.feature p{margin-bottom:0}.feature h3{margin-top:10px}.land-bottom{padding:22px}.land-bottom>div{display:block}.land-bottom nav{margin-top:20px}.public-page{padding:28px 16px}.public-page>.panel{padding:23px}.public-page h1{font-size:27px}.link-field{flex-wrap:wrap}.link-field input{flex-basis:100%}.form-grid{gap:10px}.form input,.form select{font-size:16px}.form textarea{font-size:16px}.dialog-head{padding:17px 19px}#modal-body{padding:20px}#toast{bottom:16px;right:16px;font-size:12px}}
+@media(prefers-reduced-motion:reduce){*,*:before,*:after{scroll-behavior:auto!important;animation:none!important;transition:none!important}}
+.side-account>div:nth-child(2){min-width:0;flex:1}.side-account .account-name{max-width:100%}.side-account .icon-button{flex-shrink:0}.side-note .line-art>svg{width:100%;height:85px;opacity:.7}
diff --git a/public/static/utils.js b/public/static/utils.js
new file mode 100644
index 0000000..1d69c0c
--- /dev/null
+++ b/public/static/utils.js
@@ -0,0 +1,18 @@
+export function priority(score) { return score < 40 ? 'Emergency' : score < 70 ? 'High' : 'Low'; }
+export function color(score) { return score < 40 ? '#ba5550' : score < 70 ? '#ca9847' : '#3f9571'; }
+export function summary(assets, reports) {
+ return { total: assets.length, average: assets.length ? assets.reduce((sum, a) => sum + a.health_score, 0) / assets.length : 0,
+ attention: assets.filter(a => a.health_score < 70).length, open: reports.filter(r => r.status === 'Open').length };
+}
+export function filterAssets(assets, query = '', type = '', risk = '') {
+ const term = query.trim().toLocaleLowerCase();
+ return assets.filter(a => (!term || `${a.name} ${a.id}`.toLocaleLowerCase().includes(term)) && (!type || a.asset_type === type) && (!risk || priority(a.health_score) === risk));
+}
+export function date(value, time = false) {
+ if (!value) return 'Not recorded';
+ const parsed = new Date(value);
+ if (Number.isNaN(parsed.valueOf())) return 'Not recorded';
+ return new Intl.DateTimeFormat(undefined, { day: 'numeric', month: 'short', ...(time ? { hour: '2-digit', minute: '2-digit' } : { year: 'numeric' }) }).format(parsed);
+}
+export function initials(name) { return name.split(/\s+/).filter(Boolean).slice(0, 2).map(s => s[0]).join('').toUpperCase(); }
+export function severityName(value) { return ({ 5: 'Low', 10: 'Medium', 15: 'High' })[value] || 'Unrated'; }
diff --git a/public/static/vendor/LEAFLET-LICENSE b/public/static/vendor/LEAFLET-LICENSE
new file mode 100644
index 0000000..bcb3ba1
--- /dev/null
+++ b/public/static/vendor/LEAFLET-LICENSE
@@ -0,0 +1,26 @@
+BSD 2-Clause License
+
+Copyright (c) 2010-2023, Volodymyr Agafonkin
+Copyright (c) 2010-2011, CloudMade
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice, this
+ list of conditions and the following disclaimer.
+
+2. Redistributions in binary form must reproduce the above copyright notice,
+ this list of conditions and the following disclaimer in the documentation
+ and/or other materials provided with the distribution.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/public/static/vendor/leaflet.css b/public/static/vendor/leaflet.css
new file mode 100644
index 0000000..2961b76
--- /dev/null
+++ b/public/static/vendor/leaflet.css
@@ -0,0 +1,661 @@
+/* required styles */
+
+.leaflet-pane,
+.leaflet-tile,
+.leaflet-marker-icon,
+.leaflet-marker-shadow,
+.leaflet-tile-container,
+.leaflet-pane > svg,
+.leaflet-pane > canvas,
+.leaflet-zoom-box,
+.leaflet-image-layer,
+.leaflet-layer {
+ position: absolute;
+ left: 0;
+ top: 0;
+ }
+.leaflet-container {
+ overflow: hidden;
+ }
+.leaflet-tile,
+.leaflet-marker-icon,
+.leaflet-marker-shadow {
+ -webkit-user-select: none;
+ -moz-user-select: none;
+ user-select: none;
+ -webkit-user-drag: none;
+ }
+/* Prevents IE11 from highlighting tiles in blue */
+.leaflet-tile::selection {
+ background: transparent;
+}
+/* Safari renders non-retina tile on retina better with this, but Chrome is worse */
+.leaflet-safari .leaflet-tile {
+ image-rendering: -webkit-optimize-contrast;
+ }
+/* hack that prevents hw layers "stretching" when loading new tiles */
+.leaflet-safari .leaflet-tile-container {
+ width: 1600px;
+ height: 1600px;
+ -webkit-transform-origin: 0 0;
+ }
+.leaflet-marker-icon,
+.leaflet-marker-shadow {
+ display: block;
+ }
+/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */
+/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */
+.leaflet-container .leaflet-overlay-pane svg {
+ max-width: none !important;
+ max-height: none !important;
+ }
+.leaflet-container .leaflet-marker-pane img,
+.leaflet-container .leaflet-shadow-pane img,
+.leaflet-container .leaflet-tile-pane img,
+.leaflet-container img.leaflet-image-layer,
+.leaflet-container .leaflet-tile {
+ max-width: none !important;
+ max-height: none !important;
+ width: auto;
+ padding: 0;
+ }
+
+.leaflet-container img.leaflet-tile {
+ /* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */
+ mix-blend-mode: plus-lighter;
+}
+
+.leaflet-container.leaflet-touch-zoom {
+ -ms-touch-action: pan-x pan-y;
+ touch-action: pan-x pan-y;
+ }
+.leaflet-container.leaflet-touch-drag {
+ -ms-touch-action: pinch-zoom;
+ /* Fallback for FF which doesn't support pinch-zoom */
+ touch-action: none;
+ touch-action: pinch-zoom;
+}
+.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom {
+ -ms-touch-action: none;
+ touch-action: none;
+}
+.leaflet-container {
+ -webkit-tap-highlight-color: transparent;
+}
+.leaflet-container a {
+ -webkit-tap-highlight-color: rgba(51, 181, 229, 0.4);
+}
+.leaflet-tile {
+ filter: inherit;
+ visibility: hidden;
+ }
+.leaflet-tile-loaded {
+ visibility: inherit;
+ }
+.leaflet-zoom-box {
+ width: 0;
+ height: 0;
+ -moz-box-sizing: border-box;
+ box-sizing: border-box;
+ z-index: 800;
+ }
+/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */
+.leaflet-overlay-pane svg {
+ -moz-user-select: none;
+ }
+
+.leaflet-pane { z-index: 400; }
+
+.leaflet-tile-pane { z-index: 200; }
+.leaflet-overlay-pane { z-index: 400; }
+.leaflet-shadow-pane { z-index: 500; }
+.leaflet-marker-pane { z-index: 600; }
+.leaflet-tooltip-pane { z-index: 650; }
+.leaflet-popup-pane { z-index: 700; }
+
+.leaflet-map-pane canvas { z-index: 100; }
+.leaflet-map-pane svg { z-index: 200; }
+
+.leaflet-vml-shape {
+ width: 1px;
+ height: 1px;
+ }
+.lvml {
+ behavior: url(#default#VML);
+ display: inline-block;
+ position: absolute;
+ }
+
+
+/* control positioning */
+
+.leaflet-control {
+ position: relative;
+ z-index: 800;
+ pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
+ pointer-events: auto;
+ }
+.leaflet-top,
+.leaflet-bottom {
+ position: absolute;
+ z-index: 1000;
+ pointer-events: none;
+ }
+.leaflet-top {
+ top: 0;
+ }
+.leaflet-right {
+ right: 0;
+ }
+.leaflet-bottom {
+ bottom: 0;
+ }
+.leaflet-left {
+ left: 0;
+ }
+.leaflet-control {
+ float: left;
+ clear: both;
+ }
+.leaflet-right .leaflet-control {
+ float: right;
+ }
+.leaflet-top .leaflet-control {
+ margin-top: 10px;
+ }
+.leaflet-bottom .leaflet-control {
+ margin-bottom: 10px;
+ }
+.leaflet-left .leaflet-control {
+ margin-left: 10px;
+ }
+.leaflet-right .leaflet-control {
+ margin-right: 10px;
+ }
+
+
+/* zoom and fade animations */
+
+.leaflet-fade-anim .leaflet-popup {
+ opacity: 0;
+ -webkit-transition: opacity 0.2s linear;
+ -moz-transition: opacity 0.2s linear;
+ transition: opacity 0.2s linear;
+ }
+.leaflet-fade-anim .leaflet-map-pane .leaflet-popup {
+ opacity: 1;
+ }
+.leaflet-zoom-animated {
+ -webkit-transform-origin: 0 0;
+ -ms-transform-origin: 0 0;
+ transform-origin: 0 0;
+ }
+svg.leaflet-zoom-animated {
+ will-change: transform;
+}
+
+.leaflet-zoom-anim .leaflet-zoom-animated {
+ -webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1);
+ -moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1);
+ transition: transform 0.25s cubic-bezier(0,0,0.25,1);
+ }
+.leaflet-zoom-anim .leaflet-tile,
+.leaflet-pan-anim .leaflet-tile {
+ -webkit-transition: none;
+ -moz-transition: none;
+ transition: none;
+ }
+
+.leaflet-zoom-anim .leaflet-zoom-hide {
+ visibility: hidden;
+ }
+
+
+/* cursors */
+
+.leaflet-interactive {
+ cursor: pointer;
+ }
+.leaflet-grab {
+ cursor: -webkit-grab;
+ cursor: -moz-grab;
+ cursor: grab;
+ }
+.leaflet-crosshair,
+.leaflet-crosshair .leaflet-interactive {
+ cursor: crosshair;
+ }
+.leaflet-popup-pane,
+.leaflet-control {
+ cursor: auto;
+ }
+.leaflet-dragging .leaflet-grab,
+.leaflet-dragging .leaflet-grab .leaflet-interactive,
+.leaflet-dragging .leaflet-marker-draggable {
+ cursor: move;
+ cursor: -webkit-grabbing;
+ cursor: -moz-grabbing;
+ cursor: grabbing;
+ }
+
+/* marker & overlays interactivity */
+.leaflet-marker-icon,
+.leaflet-marker-shadow,
+.leaflet-image-layer,
+.leaflet-pane > svg path,
+.leaflet-tile-container {
+ pointer-events: none;
+ }
+
+.leaflet-marker-icon.leaflet-interactive,
+.leaflet-image-layer.leaflet-interactive,
+.leaflet-pane > svg path.leaflet-interactive,
+svg.leaflet-image-layer.leaflet-interactive path {
+ pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
+ pointer-events: auto;
+ }
+
+/* visual tweaks */
+
+.leaflet-container {
+ background: #ddd;
+ outline-offset: 1px;
+ }
+.leaflet-container a {
+ color: #0078A8;
+ }
+.leaflet-zoom-box {
+ border: 2px dotted #38f;
+ background: rgba(255,255,255,0.5);
+ }
+
+
+/* general typography */
+.leaflet-container {
+ font-family: "Helvetica Neue", Arial, Helvetica, sans-serif;
+ font-size: 12px;
+ font-size: 0.75rem;
+ line-height: 1.5;
+ }
+
+
+/* general toolbar styles */
+
+.leaflet-bar {
+ box-shadow: 0 1px 5px rgba(0,0,0,0.65);
+ border-radius: 4px;
+ }
+.leaflet-bar a {
+ background-color: #fff;
+ border-bottom: 1px solid #ccc;
+ width: 26px;
+ height: 26px;
+ line-height: 26px;
+ display: block;
+ text-align: center;
+ text-decoration: none;
+ color: black;
+ }
+.leaflet-bar a,
+.leaflet-control-layers-toggle {
+ background-position: 50% 50%;
+ background-repeat: no-repeat;
+ display: block;
+ }
+.leaflet-bar a:hover,
+.leaflet-bar a:focus {
+ background-color: #f4f4f4;
+ }
+.leaflet-bar a:first-child {
+ border-top-left-radius: 4px;
+ border-top-right-radius: 4px;
+ }
+.leaflet-bar a:last-child {
+ border-bottom-left-radius: 4px;
+ border-bottom-right-radius: 4px;
+ border-bottom: none;
+ }
+.leaflet-bar a.leaflet-disabled {
+ cursor: default;
+ background-color: #f4f4f4;
+ color: #bbb;
+ }
+
+.leaflet-touch .leaflet-bar a {
+ width: 30px;
+ height: 30px;
+ line-height: 30px;
+ }
+.leaflet-touch .leaflet-bar a:first-child {
+ border-top-left-radius: 2px;
+ border-top-right-radius: 2px;
+ }
+.leaflet-touch .leaflet-bar a:last-child {
+ border-bottom-left-radius: 2px;
+ border-bottom-right-radius: 2px;
+ }
+
+/* zoom control */
+
+.leaflet-control-zoom-in,
+.leaflet-control-zoom-out {
+ font: bold 18px 'Lucida Console', Monaco, monospace;
+ text-indent: 1px;
+ }
+
+.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out {
+ font-size: 22px;
+ }
+
+
+/* layers control */
+
+.leaflet-control-layers {
+ box-shadow: 0 1px 5px rgba(0,0,0,0.4);
+ background: #fff;
+ border-radius: 5px;
+ }
+.leaflet-control-layers-toggle {
+ background-image: url(images/layers.png);
+ width: 36px;
+ height: 36px;
+ }
+.leaflet-retina .leaflet-control-layers-toggle {
+ background-image: url(images/layers-2x.png);
+ background-size: 26px 26px;
+ }
+.leaflet-touch .leaflet-control-layers-toggle {
+ width: 44px;
+ height: 44px;
+ }
+.leaflet-control-layers .leaflet-control-layers-list,
+.leaflet-control-layers-expanded .leaflet-control-layers-toggle {
+ display: none;
+ }
+.leaflet-control-layers-expanded .leaflet-control-layers-list {
+ display: block;
+ position: relative;
+ }
+.leaflet-control-layers-expanded {
+ padding: 6px 10px 6px 6px;
+ color: #333;
+ background: #fff;
+ }
+.leaflet-control-layers-scrollbar {
+ overflow-y: scroll;
+ overflow-x: hidden;
+ padding-right: 5px;
+ }
+.leaflet-control-layers-selector {
+ margin-top: 2px;
+ position: relative;
+ top: 1px;
+ }
+.leaflet-control-layers label {
+ display: block;
+ font-size: 13px;
+ font-size: 1.08333em;
+ }
+.leaflet-control-layers-separator {
+ height: 0;
+ border-top: 1px solid #ddd;
+ margin: 5px -10px 5px -6px;
+ }
+
+/* Default icon URLs */
+.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */
+ background-image: url(images/marker-icon.png);
+ }
+
+
+/* attribution and scale controls */
+
+.leaflet-container .leaflet-control-attribution {
+ background: #fff;
+ background: rgba(255, 255, 255, 0.8);
+ margin: 0;
+ }
+.leaflet-control-attribution,
+.leaflet-control-scale-line {
+ padding: 0 5px;
+ color: #333;
+ line-height: 1.4;
+ }
+.leaflet-control-attribution a {
+ text-decoration: none;
+ }
+.leaflet-control-attribution a:hover,
+.leaflet-control-attribution a:focus {
+ text-decoration: underline;
+ }
+.leaflet-attribution-flag {
+ display: inline !important;
+ vertical-align: baseline !important;
+ width: 1em;
+ height: 0.6669em;
+ }
+.leaflet-left .leaflet-control-scale {
+ margin-left: 5px;
+ }
+.leaflet-bottom .leaflet-control-scale {
+ margin-bottom: 5px;
+ }
+.leaflet-control-scale-line {
+ border: 2px solid #777;
+ border-top: none;
+ line-height: 1.1;
+ padding: 2px 5px 1px;
+ white-space: nowrap;
+ -moz-box-sizing: border-box;
+ box-sizing: border-box;
+ background: rgba(255, 255, 255, 0.8);
+ text-shadow: 1px 1px #fff;
+ }
+.leaflet-control-scale-line:not(:first-child) {
+ border-top: 2px solid #777;
+ border-bottom: none;
+ margin-top: -2px;
+ }
+.leaflet-control-scale-line:not(:first-child):not(:last-child) {
+ border-bottom: 2px solid #777;
+ }
+
+.leaflet-touch .leaflet-control-attribution,
+.leaflet-touch .leaflet-control-layers,
+.leaflet-touch .leaflet-bar {
+ box-shadow: none;
+ }
+.leaflet-touch .leaflet-control-layers,
+.leaflet-touch .leaflet-bar {
+ border: 2px solid rgba(0,0,0,0.2);
+ background-clip: padding-box;
+ }
+
+
+/* popup */
+
+.leaflet-popup {
+ position: absolute;
+ text-align: center;
+ margin-bottom: 20px;
+ }
+.leaflet-popup-content-wrapper {
+ padding: 1px;
+ text-align: left;
+ border-radius: 12px;
+ }
+.leaflet-popup-content {
+ margin: 13px 24px 13px 20px;
+ line-height: 1.3;
+ font-size: 13px;
+ font-size: 1.08333em;
+ min-height: 1px;
+ }
+.leaflet-popup-content p {
+ margin: 17px 0;
+ margin: 1.3em 0;
+ }
+.leaflet-popup-tip-container {
+ width: 40px;
+ height: 20px;
+ position: absolute;
+ left: 50%;
+ margin-top: -1px;
+ margin-left: -20px;
+ overflow: hidden;
+ pointer-events: none;
+ }
+.leaflet-popup-tip {
+ width: 17px;
+ height: 17px;
+ padding: 1px;
+
+ margin: -10px auto 0;
+ pointer-events: auto;
+
+ -webkit-transform: rotate(45deg);
+ -moz-transform: rotate(45deg);
+ -ms-transform: rotate(45deg);
+ transform: rotate(45deg);
+ }
+.leaflet-popup-content-wrapper,
+.leaflet-popup-tip {
+ background: white;
+ color: #333;
+ box-shadow: 0 3px 14px rgba(0,0,0,0.4);
+ }
+.leaflet-container a.leaflet-popup-close-button {
+ position: absolute;
+ top: 0;
+ right: 0;
+ border: none;
+ text-align: center;
+ width: 24px;
+ height: 24px;
+ font: 16px/24px Tahoma, Verdana, sans-serif;
+ color: #757575;
+ text-decoration: none;
+ background: transparent;
+ }
+.leaflet-container a.leaflet-popup-close-button:hover,
+.leaflet-container a.leaflet-popup-close-button:focus {
+ color: #585858;
+ }
+.leaflet-popup-scrolled {
+ overflow: auto;
+ }
+
+.leaflet-oldie .leaflet-popup-content-wrapper {
+ -ms-zoom: 1;
+ }
+.leaflet-oldie .leaflet-popup-tip {
+ width: 24px;
+ margin: 0 auto;
+
+ -ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)";
+ filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678);
+ }
+
+.leaflet-oldie .leaflet-control-zoom,
+.leaflet-oldie .leaflet-control-layers,
+.leaflet-oldie .leaflet-popup-content-wrapper,
+.leaflet-oldie .leaflet-popup-tip {
+ border: 1px solid #999;
+ }
+
+
+/* div icon */
+
+.leaflet-div-icon {
+ background: #fff;
+ border: 1px solid #666;
+ }
+
+
+/* Tooltip */
+/* Base styles for the element that has a tooltip */
+.leaflet-tooltip {
+ position: absolute;
+ padding: 6px;
+ background-color: #fff;
+ border: 1px solid #fff;
+ border-radius: 3px;
+ color: #222;
+ white-space: nowrap;
+ -webkit-user-select: none;
+ -moz-user-select: none;
+ -ms-user-select: none;
+ user-select: none;
+ pointer-events: none;
+ box-shadow: 0 1px 3px rgba(0,0,0,0.4);
+ }
+.leaflet-tooltip.leaflet-interactive {
+ cursor: pointer;
+ pointer-events: auto;
+ }
+.leaflet-tooltip-top:before,
+.leaflet-tooltip-bottom:before,
+.leaflet-tooltip-left:before,
+.leaflet-tooltip-right:before {
+ position: absolute;
+ pointer-events: none;
+ border: 6px solid transparent;
+ background: transparent;
+ content: "";
+ }
+
+/* Directions */
+
+.leaflet-tooltip-bottom {
+ margin-top: 6px;
+}
+.leaflet-tooltip-top {
+ margin-top: -6px;
+}
+.leaflet-tooltip-bottom:before,
+.leaflet-tooltip-top:before {
+ left: 50%;
+ margin-left: -6px;
+ }
+.leaflet-tooltip-top:before {
+ bottom: 0;
+ margin-bottom: -12px;
+ border-top-color: #fff;
+ }
+.leaflet-tooltip-bottom:before {
+ top: 0;
+ margin-top: -12px;
+ margin-left: -6px;
+ border-bottom-color: #fff;
+ }
+.leaflet-tooltip-left {
+ margin-left: -6px;
+}
+.leaflet-tooltip-right {
+ margin-left: 6px;
+}
+.leaflet-tooltip-left:before,
+.leaflet-tooltip-right:before {
+ top: 50%;
+ margin-top: -6px;
+ }
+.leaflet-tooltip-left:before {
+ right: 0;
+ margin-right: -12px;
+ border-left-color: #fff;
+ }
+.leaflet-tooltip-right:before {
+ left: 0;
+ margin-left: -12px;
+ border-right-color: #fff;
+ }
+
+/* Printing */
+
+@media print {
+ /* Prevent printers from removing background-images of controls. */
+ .leaflet-control {
+ -webkit-print-color-adjust: exact;
+ print-color-adjust: exact;
+ }
+ }
diff --git a/public/static/vendor/leaflet.js b/public/static/vendor/leaflet.js
new file mode 100644
index 0000000..a3bf693
--- /dev/null
+++ b/public/static/vendor/leaflet.js
@@ -0,0 +1,6 @@
+/* @preserve
+ * Leaflet 1.9.4, a JS library for interactive maps. https://leafletjs.com
+ * (c) 2010-2023 Vladimir Agafonkin, (c) 2010-2011 CloudMade
+ */
+!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports):"function"==typeof define&&define.amd?define(["exports"],e):e((t="undefined"!=typeof globalThis?globalThis:t||self).leaflet={})}(this,function(t){"use strict";function l(t){for(var e,i,n=1,o=arguments.length;n
=this.min.x&&i.x<=this.max.x&&e.y>=this.min.y&&i.y<=this.max.y},intersects:function(t){t=_(t);var e=this.min,i=this.max,n=t.min,t=t.max,o=t.x>=e.x&&n.x<=i.x,t=t.y>=e.y&&n.y<=i.y;return o&&t},overlaps:function(t){t=_(t);var e=this.min,i=this.max,n=t.min,t=t.max,o=t.x>e.x&&n.xe.y&&n.y=n.lat&&i.lat<=o.lat&&e.lng>=n.lng&&i.lng<=o.lng},intersects:function(t){t=g(t);var e=this._southWest,i=this._northEast,n=t.getSouthWest(),t=t.getNorthEast(),o=t.lat>=e.lat&&n.lat<=i.lat,t=t.lng>=e.lng&&n.lng<=i.lng;return o&&t},overlaps:function(t){t=g(t);var e=this._southWest,i=this._northEast,n=t.getSouthWest(),t=t.getNorthEast(),o=t.lat>e.lat&&n.late.lng&&n.lng ","http://www.w3.org/2000/svg"===(Wt.firstChild&&Wt.firstChild.namespaceURI));function y(t){return 0<=navigator.userAgent.toLowerCase().indexOf(t)}var b={ie:pt,ielt9:mt,edge:n,webkit:ft,android:gt,android23:vt,androidStock:yt,opera:xt,chrome:wt,gecko:bt,safari:Pt,phantom:Lt,opera12:o,win:Tt,ie3d:Mt,webkit3d:zt,gecko3d:_t,any3d:Ct,mobile:Zt,mobileWebkit:St,mobileWebkit3d:Et,msPointer:kt,pointer:Ot,touch:Bt,touchNative:At,mobileOpera:It,mobileGecko:Rt,retina:Nt,passiveEvents:Dt,canvas:jt,svg:Ht,vml:!Ht&&function(){try{var t=document.createElement("div"),e=(t.innerHTML=' ',t.firstChild);return e.style.behavior="url(#default#VML)",e&&"object"==typeof e.adj}catch(t){return!1}}(),inlineSvg:Wt,mac:0===navigator.platform.indexOf("Mac"),linux:0===navigator.platform.indexOf("Linux")},Ft=b.msPointer?"MSPointerDown":"pointerdown",Ut=b.msPointer?"MSPointerMove":"pointermove",Vt=b.msPointer?"MSPointerUp":"pointerup",qt=b.msPointer?"MSPointerCancel":"pointercancel",Gt={touchstart:Ft,touchmove:Ut,touchend:Vt,touchcancel:qt},Kt={touchstart:function(t,e){e.MSPOINTER_TYPE_TOUCH&&e.pointerType===e.MSPOINTER_TYPE_TOUCH&&O(e);ee(t,e)},touchmove:ee,touchend:ee,touchcancel:ee},Yt={},Xt=!1;function Jt(t,e,i){return"touchstart"!==e||Xt||(document.addEventListener(Ft,$t,!0),document.addEventListener(Ut,Qt,!0),document.addEventListener(Vt,te,!0),document.addEventListener(qt,te,!0),Xt=!0),Kt[e]?(i=Kt[e].bind(this,i),t.addEventListener(Gt[e],i,!1),i):(console.warn("wrong event specified:",e),u)}function $t(t){Yt[t.pointerId]=t}function Qt(t){Yt[t.pointerId]&&(Yt[t.pointerId]=t)}function te(t){delete Yt[t.pointerId]}function ee(t,e){if(e.pointerType!==(e.MSPOINTER_TYPE_MOUSE||"mouse")){for(var i in e.touches=[],Yt)e.touches.push(Yt[i]);e.changedTouches=[e],t(e)}}var ie=200;function ne(t,i){t.addEventListener("dblclick",i);var n,o=0;function e(t){var e;1!==t.detail?n=t.detail:"mouse"===t.pointerType||t.sourceCapabilities&&!t.sourceCapabilities.firesTouchEvents||((e=Ne(t)).some(function(t){return t instanceof HTMLLabelElement&&t.attributes.for})&&!e.some(function(t){return t instanceof HTMLInputElement||t instanceof HTMLSelectElement})||((e=Date.now())-o<=ie?2===++n&&i(function(t){var e,i,n={};for(i in t)e=t[i],n[i]=e&&e.bind?e.bind(t):e;return(t=n).type="dblclick",n.detail=2,n.isTrusted=!1,n._simulated=!0,n}(t)):n=1,o=e))}return t.addEventListener("click",e),{dblclick:i,simDblclick:e}}var oe,se,re,ae,he,le,ue=we(["transform","webkitTransform","OTransform","MozTransform","msTransform"]),ce=we(["webkitTransition","transition","OTransition","MozTransition","msTransition"]),de="webkitTransition"===ce||"OTransition"===ce?ce+"End":"transitionend";function _e(t){return"string"==typeof t?document.getElementById(t):t}function pe(t,e){var i=t.style[e]||t.currentStyle&&t.currentStyle[e];return"auto"===(i=i&&"auto"!==i||!document.defaultView?i:(t=document.defaultView.getComputedStyle(t,null))?t[e]:null)?null:i}function P(t,e,i){t=document.createElement(t);return t.className=e||"",i&&i.appendChild(t),t}function T(t){var e=t.parentNode;e&&e.removeChild(t)}function me(t){for(;t.firstChild;)t.removeChild(t.firstChild)}function fe(t){var e=t.parentNode;e&&e.lastChild!==t&&e.appendChild(t)}function ge(t){var e=t.parentNode;e&&e.firstChild!==t&&e.insertBefore(t,e.firstChild)}function ve(t,e){return void 0!==t.classList?t.classList.contains(e):0<(t=xe(t)).length&&new RegExp("(^|\\s)"+e+"(\\s|$)").test(t)}function M(t,e){var i;if(void 0!==t.classList)for(var n=F(e),o=0,s=n.length;othis.options.maxZoom)?this.setZoom(t):this},panInsideBounds:function(t,e){this._enforcingBounds=!0;var i=this.getCenter(),t=this._limitCenter(i,this._zoom,g(t));return i.equals(t)||this.panTo(t,e),this._enforcingBounds=!1,this},panInside:function(t,e){var i=m((e=e||{}).paddingTopLeft||e.padding||[0,0]),n=m(e.paddingBottomRight||e.padding||[0,0]),o=this.project(this.getCenter()),t=this.project(t),s=this.getPixelBounds(),i=_([s.min.add(i),s.max.subtract(n)]),s=i.getSize();return i.contains(t)||(this._enforcingBounds=!0,n=t.subtract(i.getCenter()),i=i.extend(t).getSize().subtract(s),o.x+=n.x<0?-i.x:i.x,o.y+=n.y<0?-i.y:i.y,this.panTo(this.unproject(o),e),this._enforcingBounds=!1),this},invalidateSize:function(t){if(!this._loaded)return this;t=l({animate:!1,pan:!0},!0===t?{animate:!0}:t);var e=this.getSize(),i=(this._sizeChanged=!0,this._lastCenter=null,this.getSize()),n=e.divideBy(2).round(),o=i.divideBy(2).round(),n=n.subtract(o);return n.x||n.y?(t.animate&&t.pan?this.panBy(n):(t.pan&&this._rawPanBy(n),this.fire("move"),t.debounceMoveend?(clearTimeout(this._sizeTimer),this._sizeTimer=setTimeout(a(this.fire,this,"moveend"),200)):this.fire("moveend")),this.fire("resize",{oldSize:e,newSize:i})):this},stop:function(){return this.setZoom(this._limitZoom(this._zoom)),this.options.zoomSnap||this.fire("viewreset"),this._stop()},locate:function(t){var e,i;return t=this._locateOptions=l({timeout:1e4,watch:!1},t),"geolocation"in navigator?(e=a(this._handleGeolocationResponse,this),i=a(this._handleGeolocationError,this),t.watch?this._locationWatchId=navigator.geolocation.watchPosition(e,i,t):navigator.geolocation.getCurrentPosition(e,i,t)):this._handleGeolocationError({code:0,message:"Geolocation not supported."}),this},stopLocate:function(){return navigator.geolocation&&navigator.geolocation.clearWatch&&navigator.geolocation.clearWatch(this._locationWatchId),this._locateOptions&&(this._locateOptions.setView=!1),this},_handleGeolocationError:function(t){var e;this._container._leaflet_id&&(e=t.code,t=t.message||(1===e?"permission denied":2===e?"position unavailable":"timeout"),this._locateOptions.setView&&!this._loaded&&this.fitWorld(),this.fire("locationerror",{code:e,message:"Geolocation error: "+t+"."}))},_handleGeolocationResponse:function(t){if(this._container._leaflet_id){var e,i,n=new v(t.coords.latitude,t.coords.longitude),o=n.toBounds(2*t.coords.accuracy),s=this._locateOptions,r=(s.setView&&(e=this.getBoundsZoom(o),this.setView(n,s.maxZoom?Math.min(e,s.maxZoom):e)),{latlng:n,bounds:o,timestamp:t.timestamp});for(i in t.coords)"number"==typeof t.coords[i]&&(r[i]=t.coords[i]);this.fire("locationfound",r)}},addHandler:function(t,e){return e&&(e=this[t]=new e(this),this._handlers.push(e),this.options[t]&&e.enable()),this},remove:function(){if(this._initEvents(!0),this.options.maxBounds&&this.off("moveend",this._panInsideMaxBounds),this._containerId!==this._container._leaflet_id)throw new Error("Map container is being reused by another instance");try{delete this._container._leaflet_id,delete this._containerId}catch(t){this._container._leaflet_id=void 0,this._containerId=void 0}for(var t in void 0!==this._locationWatchId&&this.stopLocate(),this._stop(),T(this._mapPane),this._clearControlPos&&this._clearControlPos(),this._resizeRequest&&(r(this._resizeRequest),this._resizeRequest=null),this._clearHandlers(),this._loaded&&this.fire("unload"),this._layers)this._layers[t].remove();for(t in this._panes)T(this._panes[t]);return this._layers=[],this._panes=[],delete this._mapPane,delete this._renderer,this},createPane:function(t,e){e=P("div","leaflet-pane"+(t?" leaflet-"+t.replace("Pane","")+"-pane":""),e||this._mapPane);return t&&(this._panes[t]=e),e},getCenter:function(){return this._checkIfLoaded(),this._lastCenter&&!this._moved()?this._lastCenter.clone():this.layerPointToLatLng(this._getCenterLayerPoint())},getZoom:function(){return this._zoom},getBounds:function(){var t=this.getPixelBounds();return new s(this.unproject(t.getBottomLeft()),this.unproject(t.getTopRight()))},getMinZoom:function(){return void 0===this.options.minZoom?this._layersMinZoom||0:this.options.minZoom},getMaxZoom:function(){return void 0===this.options.maxZoom?void 0===this._layersMaxZoom?1/0:this._layersMaxZoom:this.options.maxZoom},getBoundsZoom:function(t,e,i){t=g(t),i=m(i||[0,0]);var n=this.getZoom()||0,o=this.getMinZoom(),s=this.getMaxZoom(),r=t.getNorthWest(),t=t.getSouthEast(),i=this.getSize().subtract(i),t=_(this.project(t,n),this.project(r,n)).getSize(),r=b.any3d?this.options.zoomSnap:1,a=i.x/t.x,i=i.y/t.y,t=e?Math.max(a,i):Math.min(a,i),n=this.getScaleZoom(t,n);return r&&(n=Math.round(n/(r/100))*(r/100),n=e?Math.ceil(n/r)*r:Math.floor(n/r)*r),Math.max(o,Math.min(s,n))},getSize:function(){return this._size&&!this._sizeChanged||(this._size=new p(this._container.clientWidth||0,this._container.clientHeight||0),this._sizeChanged=!1),this._size.clone()},getPixelBounds:function(t,e){t=this._getTopLeftPoint(t,e);return new f(t,t.add(this.getSize()))},getPixelOrigin:function(){return this._checkIfLoaded(),this._pixelOrigin},getPixelWorldBounds:function(t){return this.options.crs.getProjectedBounds(void 0===t?this.getZoom():t)},getPane:function(t){return"string"==typeof t?this._panes[t]:t},getPanes:function(){return this._panes},getContainer:function(){return this._container},getZoomScale:function(t,e){var i=this.options.crs;return e=void 0===e?this._zoom:e,i.scale(t)/i.scale(e)},getScaleZoom:function(t,e){var i=this.options.crs,t=(e=void 0===e?this._zoom:e,i.zoom(t*i.scale(e)));return isNaN(t)?1/0:t},project:function(t,e){return e=void 0===e?this._zoom:e,this.options.crs.latLngToPoint(w(t),e)},unproject:function(t,e){return e=void 0===e?this._zoom:e,this.options.crs.pointToLatLng(m(t),e)},layerPointToLatLng:function(t){t=m(t).add(this.getPixelOrigin());return this.unproject(t)},latLngToLayerPoint:function(t){return this.project(w(t))._round()._subtract(this.getPixelOrigin())},wrapLatLng:function(t){return this.options.crs.wrapLatLng(w(t))},wrapLatLngBounds:function(t){return this.options.crs.wrapLatLngBounds(g(t))},distance:function(t,e){return this.options.crs.distance(w(t),w(e))},containerPointToLayerPoint:function(t){return m(t).subtract(this._getMapPanePos())},layerPointToContainerPoint:function(t){return m(t).add(this._getMapPanePos())},containerPointToLatLng:function(t){t=this.containerPointToLayerPoint(m(t));return this.layerPointToLatLng(t)},latLngToContainerPoint:function(t){return this.layerPointToContainerPoint(this.latLngToLayerPoint(w(t)))},mouseEventToContainerPoint:function(t){return De(t,this._container)},mouseEventToLayerPoint:function(t){return this.containerPointToLayerPoint(this.mouseEventToContainerPoint(t))},mouseEventToLatLng:function(t){return this.layerPointToLatLng(this.mouseEventToLayerPoint(t))},_initContainer:function(t){t=this._container=_e(t);if(!t)throw new Error("Map container not found.");if(t._leaflet_id)throw new Error("Map container is already initialized.");S(t,"scroll",this._onScroll,this),this._containerId=h(t)},_initLayout:function(){var t=this._container,e=(this._fadeAnimated=this.options.fadeAnimation&&b.any3d,M(t,"leaflet-container"+(b.touch?" leaflet-touch":"")+(b.retina?" leaflet-retina":"")+(b.ielt9?" leaflet-oldie":"")+(b.safari?" leaflet-safari":"")+(this._fadeAnimated?" leaflet-fade-anim":"")),pe(t,"position"));"absolute"!==e&&"relative"!==e&&"fixed"!==e&&"sticky"!==e&&(t.style.position="relative"),this._initPanes(),this._initControlPos&&this._initControlPos()},_initPanes:function(){var t=this._panes={};this._paneRenderers={},this._mapPane=this.createPane("mapPane",this._container),Z(this._mapPane,new p(0,0)),this.createPane("tilePane"),this.createPane("overlayPane"),this.createPane("shadowPane"),this.createPane("markerPane"),this.createPane("tooltipPane"),this.createPane("popupPane"),this.options.markerZoomAnimation||(M(t.markerPane,"leaflet-zoom-hide"),M(t.shadowPane,"leaflet-zoom-hide"))},_resetView:function(t,e,i){Z(this._mapPane,new p(0,0));var n=!this._loaded,o=(this._loaded=!0,e=this._limitZoom(e),this.fire("viewprereset"),this._zoom!==e);this._moveStart(o,i)._move(t,e)._moveEnd(o),this.fire("viewreset"),n&&this.fire("load")},_moveStart:function(t,e){return t&&this.fire("zoomstart"),e||this.fire("movestart"),this},_move:function(t,e,i,n){void 0===e&&(e=this._zoom);var o=this._zoom!==e;return this._zoom=e,this._lastCenter=t,this._pixelOrigin=this._getNewPixelOrigin(t),n?i&&i.pinch&&this.fire("zoom",i):((o||i&&i.pinch)&&this.fire("zoom",i),this.fire("move",i)),this},_moveEnd:function(t){return t&&this.fire("zoomend"),this.fire("moveend")},_stop:function(){return r(this._flyToFrame),this._panAnim&&this._panAnim.stop(),this},_rawPanBy:function(t){Z(this._mapPane,this._getMapPanePos().subtract(t))},_getZoomSpan:function(){return this.getMaxZoom()-this.getMinZoom()},_panInsideMaxBounds:function(){this._enforcingBounds||this.panInsideBounds(this.options.maxBounds)},_checkIfLoaded:function(){if(!this._loaded)throw new Error("Set map center and zoom first.")},_initEvents:function(t){this._targets={};var e=t?k:S;e((this._targets[h(this._container)]=this)._container,"click dblclick mousedown mouseup mouseover mouseout mousemove contextmenu keypress keydown keyup",this._handleDOMEvent,this),this.options.trackResize&&e(window,"resize",this._onResize,this),b.any3d&&this.options.transform3DLimit&&(t?this.off:this.on).call(this,"moveend",this._onMoveEnd)},_onResize:function(){r(this._resizeRequest),this._resizeRequest=x(function(){this.invalidateSize({debounceMoveend:!0})},this)},_onScroll:function(){this._container.scrollTop=0,this._container.scrollLeft=0},_onMoveEnd:function(){var t=this._getMapPanePos();Math.max(Math.abs(t.x),Math.abs(t.y))>=this.options.transform3DLimit&&this._resetView(this.getCenter(),this.getZoom())},_findEventTargets:function(t,e){for(var i,n=[],o="mouseout"===e||"mouseover"===e,s=t.target||t.srcElement,r=!1;s;){if((i=this._targets[h(s)])&&("click"===e||"preclick"===e)&&this._draggableMoved(i)){r=!0;break}if(i&&i.listens(e,!0)){if(o&&!We(s,t))break;if(n.push(i),o)break}if(s===this._container)break;s=s.parentNode}return n=n.length||r||o||!this.listens(e,!0)?n:[this]},_isClickDisabled:function(t){for(;t&&t!==this._container;){if(t._leaflet_disable_click)return!0;t=t.parentNode}},_handleDOMEvent:function(t){var e,i=t.target||t.srcElement;!this._loaded||i._leaflet_disable_events||"click"===t.type&&this._isClickDisabled(i)||("mousedown"===(e=t.type)&&Me(i),this._fireDOMEvent(t,e))},_mouseEvents:["click","dblclick","mouseover","mouseout","contextmenu"],_fireDOMEvent:function(t,e,i){"click"===t.type&&((a=l({},t)).type="preclick",this._fireDOMEvent(a,a.type,i));var n=this._findEventTargets(t,e);if(i){for(var o=[],s=0;sthis.options.zoomAnimationThreshold)return!1;var n=this.getZoomScale(e),n=this._getCenterOffset(t)._divideBy(1-1/n);if(!0!==i.animate&&!this.getSize().contains(n))return!1;x(function(){this._moveStart(!0,i.noMoveStart||!1)._animateZoom(t,e,!0)},this)}return!0},_animateZoom:function(t,e,i,n){this._mapPane&&(i&&(this._animatingZoom=!0,this._animateToCenter=t,this._animateToZoom=e,M(this._mapPane,"leaflet-zoom-anim")),this.fire("zoomanim",{center:t,zoom:e,noUpdate:n}),this._tempFireZoomEvent||(this._tempFireZoomEvent=this._zoom!==this._animateToZoom),this._move(this._animateToCenter,this._animateToZoom,void 0,!0),setTimeout(a(this._onZoomTransitionEnd,this),250))},_onZoomTransitionEnd:function(){this._animatingZoom&&(this._mapPane&&z(this._mapPane,"leaflet-zoom-anim"),this._animatingZoom=!1,this._move(this._animateToCenter,this._animateToZoom,void 0,!0),this._tempFireZoomEvent&&this.fire("zoom"),delete this._tempFireZoomEvent,this.fire("move"),this._moveEnd(!0))}});function Ue(t){return new B(t)}var B=et.extend({options:{position:"topright"},initialize:function(t){c(this,t)},getPosition:function(){return this.options.position},setPosition:function(t){var e=this._map;return e&&e.removeControl(this),this.options.position=t,e&&e.addControl(this),this},getContainer:function(){return this._container},addTo:function(t){this.remove(),this._map=t;var e=this._container=this.onAdd(t),i=this.getPosition(),t=t._controlCorners[i];return M(e,"leaflet-control"),-1!==i.indexOf("bottom")?t.insertBefore(e,t.firstChild):t.appendChild(e),this._map.on("unload",this.remove,this),this},remove:function(){return this._map&&(T(this._container),this.onRemove&&this.onRemove(this._map),this._map.off("unload",this.remove,this),this._map=null),this},_refocusOnMap:function(t){this._map&&t&&0 ",e=document.createElement("div");return e.innerHTML=t,e.firstChild},_addItem:function(t){var e,i=document.createElement("label"),n=this._map.hasLayer(t.layer),n=(t.overlay?((e=document.createElement("input")).type="checkbox",e.className="leaflet-control-layers-selector",e.defaultChecked=n):e=this._createRadioElement("leaflet-base-layers_"+h(this),n),this._layerControlInputs.push(e),e.layerId=h(t.layer),S(e,"click",this._onInputClick,this),document.createElement("span")),o=(n.innerHTML=" "+t.name,document.createElement("span"));return i.appendChild(o),o.appendChild(e),o.appendChild(n),(t.overlay?this._overlaysList:this._baseLayersList).appendChild(i),this._checkDisabledLayers(),i},_onInputClick:function(){if(!this._preventClick){var t,e,i=this._layerControlInputs,n=[],o=[];this._handlingClick=!0;for(var s=i.length-1;0<=s;s--)t=i[s],e=this._getLayer(t.layerId).layer,t.checked?n.push(e):t.checked||o.push(e);for(s=0;se.options.maxZoom},_expandIfNotCollapsed:function(){return this._map&&!this.options.collapsed&&this.expand(),this},_expandSafely:function(){var t=this._section,e=(this._preventClick=!0,S(t,"click",O),this.expand(),this);setTimeout(function(){k(t,"click",O),e._preventClick=!1})}})),qe=B.extend({options:{position:"topleft",zoomInText:'+ ',zoomInTitle:"Zoom in",zoomOutText:'− ',zoomOutTitle:"Zoom out"},onAdd:function(t){var e="leaflet-control-zoom",i=P("div",e+" leaflet-bar"),n=this.options;return this._zoomInButton=this._createButton(n.zoomInText,n.zoomInTitle,e+"-in",i,this._zoomIn),this._zoomOutButton=this._createButton(n.zoomOutText,n.zoomOutTitle,e+"-out",i,this._zoomOut),this._updateDisabled(),t.on("zoomend zoomlevelschange",this._updateDisabled,this),i},onRemove:function(t){t.off("zoomend zoomlevelschange",this._updateDisabled,this)},disable:function(){return this._disabled=!0,this._updateDisabled(),this},enable:function(){return this._disabled=!1,this._updateDisabled(),this},_zoomIn:function(t){!this._disabled&&this._map._zoomthis._map.getMinZoom()&&this._map.zoomOut(this._map.options.zoomDelta*(t.shiftKey?3:1))},_createButton:function(t,e,i,n,o){i=P("a",i,n);return i.innerHTML=t,i.href="#",i.title=e,i.setAttribute("role","button"),i.setAttribute("aria-label",e),Ie(i),S(i,"click",Re),S(i,"click",o,this),S(i,"click",this._refocusOnMap,this),i},_updateDisabled:function(){var t=this._map,e="leaflet-disabled";z(this._zoomInButton,e),z(this._zoomOutButton,e),this._zoomInButton.setAttribute("aria-disabled","false"),this._zoomOutButton.setAttribute("aria-disabled","false"),!this._disabled&&t._zoom!==t.getMinZoom()||(M(this._zoomOutButton,e),this._zoomOutButton.setAttribute("aria-disabled","true")),!this._disabled&&t._zoom!==t.getMaxZoom()||(M(this._zoomInButton,e),this._zoomInButton.setAttribute("aria-disabled","true"))}}),Ge=(A.mergeOptions({zoomControl:!0}),A.addInitHook(function(){this.options.zoomControl&&(this.zoomControl=new qe,this.addControl(this.zoomControl))}),B.extend({options:{position:"bottomleft",maxWidth:100,metric:!0,imperial:!0},onAdd:function(t){var e="leaflet-control-scale",i=P("div",e),n=this.options;return this._addScales(n,e+"-line",i),t.on(n.updateWhenIdle?"moveend":"move",this._update,this),t.whenReady(this._update,this),i},onRemove:function(t){t.off(this.options.updateWhenIdle?"moveend":"move",this._update,this)},_addScales:function(t,e,i){t.metric&&(this._mScale=P("div",e,i)),t.imperial&&(this._iScale=P("div",e,i))},_update:function(){var t=this._map,e=t.getSize().y/2,t=t.distance(t.containerPointToLatLng([0,e]),t.containerPointToLatLng([this.options.maxWidth,e]));this._updateScales(t)},_updateScales:function(t){this.options.metric&&t&&this._updateMetric(t),this.options.imperial&&t&&this._updateImperial(t)},_updateMetric:function(t){var e=this._getRoundNum(t);this._updateScale(this._mScale,e<1e3?e+" m":e/1e3+" km",e/t)},_updateImperial:function(t){var e,i,t=3.2808399*t;5280'+(b.inlineSvg?' ':"")+"Leaflet"},initialize:function(t){c(this,t),this._attributions={}},onAdd:function(t){for(var e in(t.attributionControl=this)._container=P("div","leaflet-control-attribution"),Ie(this._container),t._layers)t._layers[e].getAttribution&&this.addAttribution(t._layers[e].getAttribution());return this._update(),t.on("layeradd",this._addAttribution,this),this._container},onRemove:function(t){t.off("layeradd",this._addAttribution,this)},_addAttribution:function(t){t.layer.getAttribution&&(this.addAttribution(t.layer.getAttribution()),t.layer.once("remove",function(){this.removeAttribution(t.layer.getAttribution())},this))},setPrefix:function(t){return this.options.prefix=t,this._update(),this},addAttribution:function(t){return t&&(this._attributions[t]||(this._attributions[t]=0),this._attributions[t]++,this._update()),this},removeAttribution:function(t){return t&&this._attributions[t]&&(this._attributions[t]--,this._update()),this},_update:function(){if(this._map){var t,e=[];for(t in this._attributions)this._attributions[t]&&e.push(t);var i=[];this.options.prefix&&i.push(this.options.prefix),e.length&&i.push(e.join(", ")),this._container.innerHTML=i.join(' | ')}}}),n=(A.mergeOptions({attributionControl:!0}),A.addInitHook(function(){this.options.attributionControl&&(new Ke).addTo(this)}),B.Layers=Ve,B.Zoom=qe,B.Scale=Ge,B.Attribution=Ke,Ue.layers=function(t,e,i){return new Ve(t,e,i)},Ue.zoom=function(t){return new qe(t)},Ue.scale=function(t){return new Ge(t)},Ue.attribution=function(t){return new Ke(t)},et.extend({initialize:function(t){this._map=t},enable:function(){return this._enabled||(this._enabled=!0,this.addHooks()),this},disable:function(){return this._enabled&&(this._enabled=!1,this.removeHooks()),this},enabled:function(){return!!this._enabled}})),ft=(n.addTo=function(t,e){return t.addHandler(e,this),this},{Events:e}),Ye=b.touch?"touchstart mousedown":"mousedown",Xe=it.extend({options:{clickTolerance:3},initialize:function(t,e,i,n){c(this,n),this._element=t,this._dragStartTarget=e||t,this._preventOutline=i},enable:function(){this._enabled||(S(this._dragStartTarget,Ye,this._onDown,this),this._enabled=!0)},disable:function(){this._enabled&&(Xe._dragging===this&&this.finishDrag(!0),k(this._dragStartTarget,Ye,this._onDown,this),this._enabled=!1,this._moved=!1)},_onDown:function(t){var e,i;this._enabled&&(this._moved=!1,ve(this._element,"leaflet-zoom-anim")||(t.touches&&1!==t.touches.length?Xe._dragging===this&&this.finishDrag():Xe._dragging||t.shiftKey||1!==t.which&&1!==t.button&&!t.touches||((Xe._dragging=this)._preventOutline&&Me(this._element),Le(),re(),this._moving||(this.fire("down"),i=t.touches?t.touches[0]:t,e=Ce(this._element),this._startPoint=new p(i.clientX,i.clientY),this._startPos=Pe(this._element),this._parentScale=Ze(e),i="mousedown"===t.type,S(document,i?"mousemove":"touchmove",this._onMove,this),S(document,i?"mouseup":"touchend touchcancel",this._onUp,this)))))},_onMove:function(t){var e;this._enabled&&(t.touches&&1e&&(i.push(t[n]),o=n);oe.max.x&&(i|=2),t.ye.max.y&&(i|=8),i}function ri(t,e,i,n){var o=e.x,e=e.y,s=i.x-o,r=i.y-e,a=s*s+r*r;return 0this._layersMaxZoom&&this.setZoom(this._layersMaxZoom),void 0===this.options.minZoom&&this._layersMinZoom&&this.getZoom()t.y!=n.y>t.y&&t.x<(n.x-i.x)*(t.y-i.y)/(n.y-i.y)+i.x&&(l=!l);return l||yi.prototype._containsPoint.call(this,t,!0)}});var wi=ci.extend({initialize:function(t,e){c(this,e),this._layers={},t&&this.addData(t)},addData:function(t){var e,i,n,o=d(t)?t:t.features;if(o){for(e=0,i=o.length;e×',S(i,"click",function(t){O(t),this.close()},this))},_updateLayout:function(){var t=this._contentNode,e=t.style,i=(e.width="",e.whiteSpace="nowrap",t.offsetWidth),i=Math.min(i,this.options.maxWidth),i=(i=Math.max(i,this.options.minWidth),e.width=i+1+"px",e.whiteSpace="",e.height="",t.offsetHeight),n=this.options.maxHeight,o="leaflet-popup-scrolled";(n&&ns.x&&(r=i.x+a-s.x+o.x),i.x-r-n.x<(a=0)&&(r=i.x-n.x),i.y+e+o.y>s.y&&(a=i.y+e-s.y+o.y),i.y-a-n.y<0&&(a=i.y-n.y),(r||a)&&(this.options.keepInView&&(this._autopanning=!0),t.fire("autopanstart").panBy([r,a]))))},_getAnchor:function(){return m(this._source&&this._source._getPopupAnchor?this._source._getPopupAnchor():[0,0])}})),Ii=(A.mergeOptions({closePopupOnClick:!0}),A.include({openPopup:function(t,e,i){return this._initOverlay(Bi,t,e,i).openOn(this),this},closePopup:function(t){return(t=arguments.length?t:this._popup)&&t.close(),this}}),o.include({bindPopup:function(t,e){return this._popup=this._initOverlay(Bi,this._popup,t,e),this._popupHandlersAdded||(this.on({click:this._openPopup,keypress:this._onKeyPress,remove:this.closePopup,move:this._movePopup}),this._popupHandlersAdded=!0),this},unbindPopup:function(){return this._popup&&(this.off({click:this._openPopup,keypress:this._onKeyPress,remove:this.closePopup,move:this._movePopup}),this._popupHandlersAdded=!1,this._popup=null),this},openPopup:function(t){return this._popup&&(this instanceof ci||(this._popup._source=this),this._popup._prepareOpen(t||this._latlng)&&this._popup.openOn(this._map)),this},closePopup:function(){return this._popup&&this._popup.close(),this},togglePopup:function(){return this._popup&&this._popup.toggle(this),this},isPopupOpen:function(){return!!this._popup&&this._popup.isOpen()},setPopupContent:function(t){return this._popup&&this._popup.setContent(t),this},getPopup:function(){return this._popup},_openPopup:function(t){var e;this._popup&&this._map&&(Re(t),e=t.layer||t.target,this._popup._source!==e||e instanceof fi?(this._popup._source=e,this.openPopup(t.latlng)):this._map.hasLayer(this._popup)?this.closePopup():this.openPopup(t.latlng))},_movePopup:function(t){this._popup.setLatLng(t.latlng)},_onKeyPress:function(t){13===t.originalEvent.keyCode&&this._openPopup(t)}}),Ai.extend({options:{pane:"tooltipPane",offset:[0,0],direction:"auto",permanent:!1,sticky:!1,opacity:.9},onAdd:function(t){Ai.prototype.onAdd.call(this,t),this.setOpacity(this.options.opacity),t.fire("tooltipopen",{tooltip:this}),this._source&&(this.addEventParent(this._source),this._source.fire("tooltipopen",{tooltip:this},!0))},onRemove:function(t){Ai.prototype.onRemove.call(this,t),t.fire("tooltipclose",{tooltip:this}),this._source&&(this.removeEventParent(this._source),this._source.fire("tooltipclose",{tooltip:this},!0))},getEvents:function(){var t=Ai.prototype.getEvents.call(this);return this.options.permanent||(t.preclick=this.close),t},_initLayout:function(){var t="leaflet-tooltip "+(this.options.className||"")+" leaflet-zoom-"+(this._zoomAnimated?"animated":"hide");this._contentNode=this._container=P("div",t),this._container.setAttribute("role","tooltip"),this._container.setAttribute("id","leaflet-tooltip-"+h(this))},_updateLayout:function(){},_adjustPan:function(){},_setPosition:function(t){var e,i=this._map,n=this._container,o=i.latLngToContainerPoint(i.getCenter()),i=i.layerPointToContainerPoint(t),s=this.options.direction,r=n.offsetWidth,a=n.offsetHeight,h=m(this.options.offset),l=this._getAnchor(),i="top"===s?(e=r/2,a):"bottom"===s?(e=r/2,0):(e="center"===s?r/2:"right"===s?0:"left"===s?r:i.xthis.options.maxZoom||nthis.options.maxZoom||void 0!==this.options.minZoom&&oi.max.x)||!e.wrapLat&&(t.yi.max.y))return!1}return!this.options.bounds||(e=this._tileCoordsToBounds(t),g(this.options.bounds).overlaps(e))},_keyToBounds:function(t){return this._tileCoordsToBounds(this._keyToTileCoords(t))},_tileCoordsToNwSe:function(t){var e=this._map,i=this.getTileSize(),n=t.scaleBy(i),i=n.add(i);return[e.unproject(n,t.z),e.unproject(i,t.z)]},_tileCoordsToBounds:function(t){t=this._tileCoordsToNwSe(t),t=new s(t[0],t[1]);return t=this.options.noWrap?t:this._map.wrapLatLngBounds(t)},_tileCoordsToKey:function(t){return t.x+":"+t.y+":"+t.z},_keyToTileCoords:function(t){var t=t.split(":"),e=new p(+t[0],+t[1]);return e.z=+t[2],e},_removeTile:function(t){var e=this._tiles[t];e&&(T(e.el),delete this._tiles[t],this.fire("tileunload",{tile:e.el,coords:this._keyToTileCoords(t)}))},_initTile:function(t){M(t,"leaflet-tile");var e=this.getTileSize();t.style.width=e.x+"px",t.style.height=e.y+"px",t.onselectstart=u,t.onmousemove=u,b.ielt9&&this.options.opacity<1&&C(t,this.options.opacity)},_addTile:function(t,e){var i=this._getTilePos(t),n=this._tileCoordsToKey(t),o=this.createTile(this._wrapCoords(t),a(this._tileReady,this,t));this._initTile(o),this.createTile.length<2&&x(a(this._tileReady,this,t,null,o)),Z(o,i),this._tiles[n]={el:o,coords:t,current:!0},e.appendChild(o),this.fire("tileloadstart",{tile:o,coords:t})},_tileReady:function(t,e,i){e&&this.fire("tileerror",{error:e,tile:i,coords:t});var n=this._tileCoordsToKey(t);(i=this._tiles[n])&&(i.loaded=+new Date,this._map._fadeAnimated?(C(i.el,0),r(this._fadeFrame),this._fadeFrame=x(this._updateOpacity,this)):(i.active=!0,this._pruneTiles()),e||(M(i.el,"leaflet-tile-loaded"),this.fire("tileload",{tile:i.el,coords:t})),this._noTilesToLoad()&&(this._loading=!1,this.fire("load"),b.ielt9||!this._map._fadeAnimated?x(this._pruneTiles,this):setTimeout(a(this._pruneTiles,this),250)))},_getTilePos:function(t){return t.scaleBy(this.getTileSize()).subtract(this._level.origin)},_wrapCoords:function(t){var e=new p(this._wrapX?H(t.x,this._wrapX):t.x,this._wrapY?H(t.y,this._wrapY):t.y);return e.z=t.z,e},_pxBoundsToTileRange:function(t){var e=this.getTileSize();return new f(t.min.unscaleBy(e).floor(),t.max.unscaleBy(e).ceil().subtract([1,1]))},_noTilesToLoad:function(){for(var t in this._tiles)if(!this._tiles[t].loaded)return!1;return!0}});var Di=Ni.extend({options:{minZoom:0,maxZoom:18,subdomains:"abc",errorTileUrl:"",zoomOffset:0,tms:!1,zoomReverse:!1,detectRetina:!1,crossOrigin:!1,referrerPolicy:!1},initialize:function(t,e){this._url=t,(e=c(this,e)).detectRetina&&b.retina&&0')}}catch(t){}return function(t){return document.createElement("<"+t+' xmlns="urn:schemas-microsoft.com:vml" class="lvml">')}}(),zt={_initContainer:function(){this._container=P("div","leaflet-vml-container")},_update:function(){this._map._animatingZoom||(Wi.prototype._update.call(this),this.fire("update"))},_initPath:function(t){var e=t._container=Vi("shape");M(e,"leaflet-vml-shape "+(this.options.className||"")),e.coordsize="1 1",t._path=Vi("path"),e.appendChild(t._path),this._updateStyle(t),this._layers[h(t)]=t},_addPath:function(t){var e=t._container;this._container.appendChild(e),t.options.interactive&&t.addInteractiveTarget(e)},_removePath:function(t){var e=t._container;T(e),t.removeInteractiveTarget(e),delete this._layers[h(t)]},_updateStyle:function(t){var e=t._stroke,i=t._fill,n=t.options,o=t._container;o.stroked=!!n.stroke,o.filled=!!n.fill,n.stroke?(e=e||(t._stroke=Vi("stroke")),o.appendChild(e),e.weight=n.weight+"px",e.color=n.color,e.opacity=n.opacity,n.dashArray?e.dashStyle=d(n.dashArray)?n.dashArray.join(" "):n.dashArray.replace(/( *, *)/g," "):e.dashStyle="",e.endcap=n.lineCap.replace("butt","flat"),e.joinstyle=n.lineJoin):e&&(o.removeChild(e),t._stroke=null),n.fill?(i=i||(t._fill=Vi("fill")),o.appendChild(i),i.color=n.fillColor||n.color,i.opacity=n.fillOpacity):i&&(o.removeChild(i),t._fill=null)},_updateCircle:function(t){var e=t._point.round(),i=Math.round(t._radius),n=Math.round(t._radiusY||i);this._setPath(t,t._empty()?"M0 0":"AL "+e.x+","+e.y+" "+i+","+n+" 0,23592600")},_setPath:function(t,e){t._path.v=e},_bringToFront:function(t){fe(t._container)},_bringToBack:function(t){ge(t._container)}},qi=b.vml?Vi:ct,Gi=Wi.extend({_initContainer:function(){this._container=qi("svg"),this._container.setAttribute("pointer-events","none"),this._rootGroup=qi("g"),this._container.appendChild(this._rootGroup)},_destroyContainer:function(){T(this._container),k(this._container),delete this._container,delete this._rootGroup,delete this._svgSize},_update:function(){var t,e,i;this._map._animatingZoom&&this._bounds||(Wi.prototype._update.call(this),e=(t=this._bounds).getSize(),i=this._container,this._svgSize&&this._svgSize.equals(e)||(this._svgSize=e,i.setAttribute("width",e.x),i.setAttribute("height",e.y)),Z(i,t.min),i.setAttribute("viewBox",[t.min.x,t.min.y,e.x,e.y].join(" ")),this.fire("update"))},_initPath:function(t){var e=t._path=qi("path");t.options.className&&M(e,t.options.className),t.options.interactive&&M(e,"leaflet-interactive"),this._updateStyle(t),this._layers[h(t)]=t},_addPath:function(t){this._rootGroup||this._initContainer(),this._rootGroup.appendChild(t._path),t.addInteractiveTarget(t._path)},_removePath:function(t){T(t._path),t.removeInteractiveTarget(t._path),delete this._layers[h(t)]},_updatePath:function(t){t._project(),t._update()},_updateStyle:function(t){var e=t._path,t=t.options;e&&(t.stroke?(e.setAttribute("stroke",t.color),e.setAttribute("stroke-opacity",t.opacity),e.setAttribute("stroke-width",t.weight),e.setAttribute("stroke-linecap",t.lineCap),e.setAttribute("stroke-linejoin",t.lineJoin),t.dashArray?e.setAttribute("stroke-dasharray",t.dashArray):e.removeAttribute("stroke-dasharray"),t.dashOffset?e.setAttribute("stroke-dashoffset",t.dashOffset):e.removeAttribute("stroke-dashoffset")):e.setAttribute("stroke","none"),t.fill?(e.setAttribute("fill",t.fillColor||t.color),e.setAttribute("fill-opacity",t.fillOpacity),e.setAttribute("fill-rule",t.fillRule||"evenodd")):e.setAttribute("fill","none"))},_updatePoly:function(t,e){this._setPath(t,dt(t._parts,e))},_updateCircle:function(t){var e=t._point,i=Math.max(Math.round(t._radius),1),n="a"+i+","+(Math.max(Math.round(t._radiusY),1)||i)+" 0 1,0 ",e=t._empty()?"M0 0":"M"+(e.x-i)+","+e.y+n+2*i+",0 "+n+2*-i+",0 ";this._setPath(t,e)},_setPath:function(t,e){t._path.setAttribute("d",e)},_bringToFront:function(t){fe(t._path)},_bringToBack:function(t){ge(t._path)}});function Ki(t){return b.svg||b.vml?new Gi(t):null}b.vml&&Gi.include(zt),A.include({getRenderer:function(t){t=(t=t.options.renderer||this._getPaneRenderer(t.options.pane)||this.options.renderer||this._renderer)||(this._renderer=this._createRenderer());return this.hasLayer(t)||this.addLayer(t),t},_getPaneRenderer:function(t){var e;return"overlayPane"!==t&&void 0!==t&&(void 0===(e=this._paneRenderers[t])&&(e=this._createRenderer({pane:t}),this._paneRenderers[t]=e),e)},_createRenderer:function(t){return this.options.preferCanvas&&Ui(t)||Ki(t)}});var Yi=xi.extend({initialize:function(t,e){xi.prototype.initialize.call(this,this._boundsToLatLngs(t),e)},setBounds:function(t){return this.setLatLngs(this._boundsToLatLngs(t))},_boundsToLatLngs:function(t){return[(t=g(t)).getSouthWest(),t.getNorthWest(),t.getNorthEast(),t.getSouthEast()]}});Gi.create=qi,Gi.pointsToPath=dt,wi.geometryToLayer=bi,wi.coordsToLatLng=Li,wi.coordsToLatLngs=Ti,wi.latLngToCoords=Mi,wi.latLngsToCoords=zi,wi.getFeature=Ci,wi.asFeature=Zi,A.mergeOptions({boxZoom:!0});var _t=n.extend({initialize:function(t){this._map=t,this._container=t._container,this._pane=t._panes.overlayPane,this._resetStateTimeout=0,t.on("unload",this._destroy,this)},addHooks:function(){S(this._container,"mousedown",this._onMouseDown,this)},removeHooks:function(){k(this._container,"mousedown",this._onMouseDown,this)},moved:function(){return this._moved},_destroy:function(){T(this._pane),delete this._pane},_resetState:function(){this._resetStateTimeout=0,this._moved=!1},_clearDeferredResetState:function(){0!==this._resetStateTimeout&&(clearTimeout(this._resetStateTimeout),this._resetStateTimeout=0)},_onMouseDown:function(t){if(!t.shiftKey||1!==t.which&&1!==t.button)return!1;this._clearDeferredResetState(),this._resetState(),re(),Le(),this._startPoint=this._map.mouseEventToContainerPoint(t),S(document,{contextmenu:Re,mousemove:this._onMouseMove,mouseup:this._onMouseUp,keydown:this._onKeyDown},this)},_onMouseMove:function(t){this._moved||(this._moved=!0,this._box=P("div","leaflet-zoom-box",this._container),M(this._container,"leaflet-crosshair"),this._map.fire("boxzoomstart")),this._point=this._map.mouseEventToContainerPoint(t);var t=new f(this._point,this._startPoint),e=t.getSize();Z(this._box,t.min),this._box.style.width=e.x+"px",this._box.style.height=e.y+"px"},_finish:function(){this._moved&&(T(this._box),z(this._container,"leaflet-crosshair")),ae(),Te(),k(document,{contextmenu:Re,mousemove:this._onMouseMove,mouseup:this._onMouseUp,keydown:this._onKeyDown},this)},_onMouseUp:function(t){1!==t.which&&1!==t.button||(this._finish(),this._moved&&(this._clearDeferredResetState(),this._resetStateTimeout=setTimeout(a(this._resetState,this),0),t=new s(this._map.containerPointToLatLng(this._startPoint),this._map.containerPointToLatLng(this._point)),this._map.fitBounds(t).fire("boxzoomend",{boxZoomBounds:t})))},_onKeyDown:function(t){27===t.keyCode&&(this._finish(),this._clearDeferredResetState(),this._resetState())}}),Ct=(A.addInitHook("addHandler","boxZoom",_t),A.mergeOptions({doubleClickZoom:!0}),n.extend({addHooks:function(){this._map.on("dblclick",this._onDoubleClick,this)},removeHooks:function(){this._map.off("dblclick",this._onDoubleClick,this)},_onDoubleClick:function(t){var e=this._map,i=e.getZoom(),n=e.options.zoomDelta,i=t.originalEvent.shiftKey?i-n:i+n;"center"===e.options.doubleClickZoom?e.setZoom(i):e.setZoomAround(t.containerPoint,i)}})),Zt=(A.addInitHook("addHandler","doubleClickZoom",Ct),A.mergeOptions({dragging:!0,inertia:!0,inertiaDeceleration:3400,inertiaMaxSpeed:1/0,easeLinearity:.2,worldCopyJump:!1,maxBoundsViscosity:0}),n.extend({addHooks:function(){var t;this._draggable||(t=this._map,this._draggable=new Xe(t._mapPane,t._container),this._draggable.on({dragstart:this._onDragStart,drag:this._onDrag,dragend:this._onDragEnd},this),this._draggable.on("predrag",this._onPreDragLimit,this),t.options.worldCopyJump&&(this._draggable.on("predrag",this._onPreDragWrap,this),t.on("zoomend",this._onZoomEnd,this),t.whenReady(this._onZoomEnd,this))),M(this._map._container,"leaflet-grab leaflet-touch-drag"),this._draggable.enable(),this._positions=[],this._times=[]},removeHooks:function(){z(this._map._container,"leaflet-grab"),z(this._map._container,"leaflet-touch-drag"),this._draggable.disable()},moved:function(){return this._draggable&&this._draggable._moved},moving:function(){return this._draggable&&this._draggable._moving},_onDragStart:function(){var t,e=this._map;e._stop(),this._map.options.maxBounds&&this._map.options.maxBoundsViscosity?(t=g(this._map.options.maxBounds),this._offsetLimit=_(this._map.latLngToContainerPoint(t.getNorthWest()).multiplyBy(-1),this._map.latLngToContainerPoint(t.getSouthEast()).multiplyBy(-1).add(this._map.getSize())),this._viscosity=Math.min(1,Math.max(0,this._map.options.maxBoundsViscosity))):this._offsetLimit=null,e.fire("movestart").fire("dragstart"),e.options.inertia&&(this._positions=[],this._times=[])},_onDrag:function(t){var e,i;this._map.options.inertia&&(e=this._lastTime=+new Date,i=this._lastPos=this._draggable._absPos||this._draggable._newPos,this._positions.push(i),this._times.push(e),this._prunePositions(e)),this._map.fire("move",t).fire("drag",t)},_prunePositions:function(t){for(;1e.max.x&&(t.x=this._viscousLimit(t.x,e.max.x)),t.y>e.max.y&&(t.y=this._viscousLimit(t.y,e.max.y)),this._draggable._newPos=this._draggable._startPos.add(t))},_onPreDragWrap:function(){var t=this._worldWidth,e=Math.round(t/2),i=this._initialWorldOffset,n=this._draggable._newPos.x,o=(n-e+i)%t+e-i,n=(n+e+i)%t-e-i,t=Math.abs(o+i)e.getMaxZoom()&&1=0.27,<1
+httpx==0.28.1
+ruff==0.16.6
diff --git a/requirements.txt b/requirements.txt
index 1e4aa82..008b94e 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,5 +1,7 @@
-fastapi
-pydantic
-python-multipart
-sqlalchemy
-uvicorn[standard]
+fastapi==0.141.1
+pydantic==2.13.5
+python-multipart==0.0.32
+sqlalchemy==2.0.52
+uvicorn[standard]==0.52.4
+Pillow==12.3.0
+psycopg[binary]==3.3.3
diff --git a/ruff.toml b/ruff.toml
new file mode 100644
index 0000000..ab33902
--- /dev/null
+++ b/ruff.toml
@@ -0,0 +1,13 @@
+target-version = "py312"
+line-length = 100
+exclude = ["public/static/vendor"]
+
+[lint]
+select = ["E4", "E7", "E9", "F", "I", "B", "UP"]
+
+[lint.flake8-bugbear]
+extend-immutable-calls = ["fastapi.Depends", "fastapi.File", "fastapi.Form", "fastapi.Query"]
+
+# Test database selection must happen before the application imports its engine.
+[lint.per-file-ignores]
+"tests/test_api.py" = ["E402"]
diff --git a/schemas.py b/schemas.py
index 416fae5..69defb3 100644
--- a/schemas.py
+++ b/schemas.py
@@ -1,11 +1,11 @@
+from datetime import datetime
from typing import Literal
-from pydantic import BaseModel, Field, field_validator
+from pydantic import BaseModel, ConfigDict, Field, field_validator
class AssetCreate(BaseModel):
- """Validated payload for registering an infrastructure asset."""
-
+ model_config = ConfigDict(extra="forbid", allow_inf_nan=False)
name: str = Field(min_length=1, max_length=120)
asset_type: Literal["Bridge", "Road", "Flyover"]
construction_year: int = Field(gt=0)
@@ -14,8 +14,54 @@ class AssetCreate(BaseModel):
@field_validator("name")
@classmethod
- def normalize_name(cls, value: str) -> str:
- normalized = value.strip()
- if not normalized:
- raise ValueError("name must contain visible characters")
- return normalized
+ def normalize_name(cls, value):
+ if not value.strip():
+ raise ValueError("Enter a visible name.")
+ return value.strip()
+
+ @field_validator("construction_year")
+ @classmethod
+ def validate_year(cls, value):
+ if value > datetime.now().year:
+ raise ValueError("Construction year cannot be in the future.")
+ return value
+
+
+class Credentials(BaseModel):
+ username: str = Field(min_length=3, max_length=32, pattern=r"^[a-zA-Z0-9_-]+$")
+ password: str = Field(min_length=10, max_length=128)
+
+ @field_validator("username")
+ @classmethod
+ def normalize_username(cls, value):
+ return value.lower()
+
+
+class Registration(Credentials):
+ name: str = Field(min_length=1, max_length=80)
+
+ @field_validator("name")
+ @classmethod
+ def normalize_name(cls, value):
+ if not value.strip():
+ raise ValueError("Enter a workspace name.")
+ return value.strip()
+
+
+class Note(BaseModel):
+ note: str = Field(min_length=5, max_length=1000)
+
+ @field_validator("note")
+ @classmethod
+ def normalize_note(cls, value):
+ if len(value.strip()) < 5:
+ raise ValueError("Describe the work in at least five characters.")
+ return value.strip()
+
+
+class Scenario(BaseModel):
+ active: bool
+
+
+class ArchiveState(BaseModel):
+ archived: bool
diff --git a/scoring.py b/scoring.py
index a8b27ce..ac61307 100644
--- a/scoring.py
+++ b/scoring.py
@@ -1,5 +1,4 @@
from datetime import datetime
-from typing import Optional, Tuple
def maintenance_priority(health_score: float) -> str:
@@ -15,8 +14,8 @@ def calculate_health(
asset_type: str,
construction_year: int,
*,
- current_year: Optional[int] = None,
-) -> Tuple[int, float, str]:
+ current_year: int | None = None,
+) -> tuple[int, float, str]:
"""Calculate age, health score, and priority for a new asset."""
year = current_year or datetime.now().year
if construction_year <= 0 or construction_year > year:
diff --git a/security.py b/security.py
new file mode 100644
index 0000000..81fe2a9
--- /dev/null
+++ b/security.py
@@ -0,0 +1,29 @@
+"""Password hashing and opaque server-side sessions."""
+
+import hashlib
+import secrets
+
+
+def hash_password(password: str) -> str:
+ salt = secrets.token_hex(16)
+ digest = hashlib.pbkdf2_hmac("sha256", password.encode(), bytes.fromhex(salt), 600_000)
+ return f"pbkdf2_sha256$600000${salt}${digest.hex()}"
+
+
+def check_password(password: str, encoded: str | None) -> bool:
+ if not encoded:
+ return False
+ try:
+ algorithm, iterations, salt, expected = encoded.split("$")
+ if algorithm != "pbkdf2_sha256":
+ return False
+ actual = hashlib.pbkdf2_hmac(
+ "sha256", password.encode(), bytes.fromhex(salt), int(iterations)
+ )
+ return secrets.compare_digest(actual.hex(), expected)
+ except (ValueError, TypeError):
+ return False
+
+
+def token_hash(token: str) -> str:
+ return hashlib.sha256(token.encode()).hexdigest()
diff --git a/seed.py b/seed.py
new file mode 100644
index 0000000..434bc0f
--- /dev/null
+++ b/seed.py
@@ -0,0 +1,35 @@
+"""Illustrative Chennai assets, never live infrastructure assessments."""
+
+import database
+
+EXAMPLES = [
+ ("Adyar Bridge", "Bridge", 1970, 13.0067, 80.2595, 72.0),
+ ("Napier Bridge", "Bridge", 1869, 13.0694, 80.2824, 92.5),
+ ("Ennore Creek Bridge", "Bridge", 2005, 13.2217, 80.3222, 89.5),
+ ("Anna Flyover", "Flyover", 1973, 13.0500, 80.2500, 73.5),
+ ("Kathipara Junction", "Flyover", 2008, 13.0067, 80.2050, 91.0),
+ ("T. Nagar Skywalk", "Flyover", 2022, 13.0333, 80.2333, 98.0),
+ ("OMR IT Expressway", "Road", 2006, 12.9228, 80.2316, 40.0),
+ ("East Coast Road", "Road", 1998, 12.8491, 80.2433, 35.0),
+ ("Mount Road", "Road", 1950, 13.0600, 80.2500, 25.0),
+]
+
+
+def seed_workspace(db, workspace):
+ added = 0
+ for name, kind, year, lat, lng, score in EXAMPLES:
+ exists = db.query(database.Asset).filter_by(workspace_id=workspace.id, name=name).first()
+ if not exists:
+ db.add(
+ database.Asset(
+ workspace_id=workspace.id,
+ name=name,
+ asset_type=kind,
+ construction_year=year,
+ latitude=lat,
+ longitude=lng,
+ condition_score=score,
+ )
+ )
+ added += 1
+ return added
diff --git a/tests-web/utils.test.mjs b/tests-web/utils.test.mjs
new file mode 100644
index 0000000..4710c44
--- /dev/null
+++ b/tests-web/utils.test.mjs
@@ -0,0 +1,8 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { priority, summary, filterAssets, date } from '../public/static/utils.js';
+const assets = [{id:1,name:'Adyar Bridge',asset_type:'Bridge',health_score:70},{id:2,name:'Mount Road',asset_type:'Road',health_score:39.9},{id:3,name:'East Coast Road',asset_type:'Road',health_score:40}];
+test('dashboard bands match documented boundary values', () => { assert.equal(priority(39.9),'Emergency'); assert.equal(priority(40),'High'); assert.equal(priority(69.9),'High'); assert.equal(priority(70),'Low'); });
+test('summaries use all assets and only open reports', () => { assert.deepEqual(summary([],[]),{total:0,average:0,attention:0,open:0}); const s=summary(assets,[{status:'Open'},{status:'Resolved'}]); assert.equal(s.total,3); assert.equal(s.attention,2); assert.equal(s.open,1); assert.ok(Math.abs(s.average-49.96666666)<1e-6); });
+test('combined search, asset type, and priority filters select matching rows', () => { assert.deepEqual(filterAssets(assets,' ROAD ','Road','High').map(a=>a.id),[3]); assert.deepEqual(filterAssets(assets,'1').map(a=>a.id),[1]); assert.equal(filterAssets(assets,'missing').length,0); });
+test('dates do not manufacture missing maintenance records', () => { assert.equal(date(null),'Not recorded'); assert.equal(date('invalid'),'Not recorded'); });
diff --git a/tests/test_api.py b/tests/test_api.py
index 321ce7b..cf983c9 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -1,61 +1,350 @@
+import csv
+import io
import os
import tempfile
import unittest
+from unittest.mock import patch
-# Set before importing the application: startup must not open the demo database.
_test_database = tempfile.TemporaryDirectory()
-os.environ["DATABASE_URL"] = "sqlite:///" + _test_database.name + "/test.db"
+os.environ["DATABASE_URL"] = (
+ os.getenv("TEST_DATABASE_URL") or "sqlite:///" + _test_database.name + "/test.db"
+)
from fastapi.testclient import TestClient
+from PIL import Image
+
import database
-from main import app
+from main import COOKIE, app
+from security import check_password
+
+HEADERS = {"X-Requested-With": "StructIQ"}
+ASSET = {
+ "name": "Test bridge",
+ "asset_type": "Bridge",
+ "construction_year": 2000,
+ "latitude": 13,
+ "longitude": 80,
+}
+
+
+def photo():
+ buffer = io.BytesIO()
+ Image.new("RGB", (40, 40), "gray").save(buffer, "PNG")
+ return buffer.getvalue()
class ApiTests(unittest.TestCase):
def setUp(self):
database.Base.metadata.drop_all(database.engine)
database.Base.metadata.create_all(database.engine)
- self.client = TestClient(app)
- self.asset = self.client.post("/assets", json={
- "name": "Test bridge", "asset_type": "Bridge", "construction_year": 2000,
- "latitude": 13, "longitude": 80,
- }).json()
+ self.client = TestClient(app, headers=HEADERS)
+ self.other = TestClient(app, headers=HEADERS)
+ self.account = self.client.post("/api/auth/demo").json()
+ self.other_account = self.other.post("/api/auth/demo").json()
+ result = self.client.post("/api/assets", json=ASSET)
+ self.assertEqual(result.status_code, 201, result.text)
+ self.asset = result.json()
+
+ def tearDown(self):
+ self.client.close()
+ self.other.close()
def upload(self, **kwargs):
- return self.client.post("/reports/upload-ai", data={
- "asset_id": self.asset["id"], "description": kwargs.pop("description", "Surface crack"),
- }, files={"file": kwargs.pop("file", ("download.png", b"demo-image", "image/png"))})
+ return self.client.post(
+ "/api/reports",
+ data={
+ "asset_id": kwargs.pop("asset_id", self.asset["id"]),
+ "description": kwargs.pop("description", "Surface crack requiring inspection"),
+ "severity": kwargs.pop("severity", "10"),
+ },
+ files={"file": kwargs.pop("file", ("evidence.png", photo(), "image/png"))},
+ )
+
+ def health(self):
+ return next(
+ a for a in self.client.get("/api/assets").json() if a["id"] == self.asset["id"]
+ )["health_score"]
- def test_resolution_is_retained_and_idempotent(self):
+ def test_workspace_isolation_and_anonymous_access(self):
+ self.assertEqual(len(self.client.get("/api/assets").json()), 10)
+ self.assertEqual(len(self.other.get("/api/assets").json()), 9)
+ stranger = TestClient(app, headers=HEADERS)
+ self.assertEqual(stranger.get("/api/assets").status_code, 401)
+ self.assertEqual(stranger.post("/api/assets", json=ASSET).status_code, 401)
+ self.assertEqual(
+ self.other.put(f"/api/assets/{self.asset['id']}", json=ASSET).status_code, 404
+ )
+ self.assertEqual(
+ self.other.post(
+ f"/api/assets/{self.asset['id']}/maintenance", json={"note": "Repair completed"}
+ ).status_code,
+ 404,
+ )
+ self.assertEqual(
+ self.other.patch(
+ f"/api/assets/{self.asset['id']}/archive", json={"archived": True}
+ ).status_code,
+ 404,
+ )
+
+ def test_registration_password_hash_login_and_logout(self):
+ credentials = {"username": "TestOperator", "password": "a long unique passphrase"}
+ response = self.client.post(
+ "/api/auth/register", json={**credentials, "name": "New workspace"}
+ )
+ self.assertEqual(response.status_code, 200, response.text)
+ with database.SessionLocal() as db:
+ account = db.query(database.Workspace).filter_by(username="testoperator").one()
+ self.assertNotIn(credentials["password"], account.password_hash)
+ self.assertTrue(check_password(credentials["password"], account.password_hash))
+ self.assertEqual(self.client.post("/api/auth/logout").status_code, 200)
+ self.assertEqual(self.client.get("/api/auth/me").status_code, 401)
+ self.assertEqual(
+ self.client.post(
+ "/api/auth/login", json={**credentials, "password": "wrong password"}
+ ).status_code,
+ 401,
+ )
+ self.assertEqual(self.client.post("/api/auth/login", json=credentials).status_code, 200)
+ self.assertEqual(self.client.get("/api/auth/me").json()["name"], "New workspace")
+ duplicate = self.other.post("/api/auth/register", json={**credentials, "name": "Duplicate"})
+ self.assertEqual(duplicate.status_code, 409)
+
+ def test_cookie_security_and_csrf_header(self):
+ cookie = self.client.cookies[COOKIE]
+ session_header = self.client.post("/api/auth/demo").headers["set-cookie"]
+ self.assertIn("HttpOnly", session_header)
+ self.assertIn("SameSite=lax", session_header)
+ plain = TestClient(app)
+ plain.cookies.set(COOKIE, cookie)
+ self.assertEqual(plain.post("/api/assets", json=ASSET).status_code, 403)
+ with patch.dict(os.environ, {"VERCEL": "1"}):
+ response = self.other.post("/api/auth/demo")
+ self.assertIn("Secure", response.headers["set-cookie"])
+
+ def test_resolution_preserves_history_and_never_inflates_score(self):
response = self.upload()
- self.assertEqual(response.status_code, 200)
- self.assertTrue(response.json()["simulated"])
- report_id = response.json()["report_id"]
- route = f"/reports/{report_id}/resolve"
- self.assertFalse(self.client.post(route).json()["already_resolved"])
- health = self.client.get("/assets").json()[0]["health_score"]
- self.assertTrue(self.client.post(route).json()["already_resolved"])
- self.assertEqual(self.client.get("/assets").json()[0]["health_score"], health)
- self.assertEqual(self.client.get("/reports?status=Open").json(), [])
- self.assertEqual(self.client.get("/reports").json()[0]["status"], "Resolved")
-
- def test_rejects_invalid_uploads_without_changing_health(self):
- for kwargs, status in [
+ self.assertEqual(response.status_code, 201, response.text)
+ report_id = response.json()["id"]
+ self.assertLess(self.health(), self.asset["health_score"])
+ route = f"/api/reports/{report_id}/resolve"
+ self.assertFalse(
+ self.client.post(route, json={"note": "Repair inspected and confirmed"}).json()[
+ "already_resolved"
+ ]
+ )
+ restored = self.health()
+ self.assertEqual(restored, self.asset["health_score"])
+ self.assertTrue(
+ self.client.post(route, json={"note": "Repeat resolution attempt"}).json()[
+ "already_resolved"
+ ]
+ )
+ self.assertEqual(self.health(), restored)
+ self.assertEqual(self.client.get("/api/reports?status=Open").json(), [])
+ history = self.client.get("/api/reports").json()[0]
+ self.assertEqual(history["status"], "Resolved")
+ self.assertEqual(history["resolution_note"], "Repair inspected and confirmed")
+ self.assertIsNotNone(history["resolved_at"])
+ self.assertEqual(
+ len([e for e in self.client.get("/api/activity").json() if e["kind"] == "resolution"]),
+ 1,
+ )
+
+ def test_multiple_report_penalties_remain_until_each_resolution(self):
+ a, b = self.upload().json(), self.upload().json()
+ self.assertEqual(self.health(), self.asset["health_score"] - 30)
+ self.client.post(f"/api/reports/{a['id']}/resolve", json={"note": "First repair completed"})
+ self.assertEqual(self.health(), self.asset["health_score"] - 15)
+ self.assertEqual(
+ self.other.post(
+ f"/api/reports/{b['id']}/resolve", json={"note": "Unauthorized resolution"}
+ ).status_code,
+ 404,
+ )
+ self.assertEqual(self.other.get(f"/api/reports/{b['id']}/image").status_code, 404)
+
+ def test_report_archive_and_restore(self):
+ report = self.upload().json()
+ endpoint = f"/api/assets/{self.asset['id']}/archive"
+ self.assertEqual(self.client.patch(endpoint, json={"archived": True}).status_code, 409)
+ self.client.post(
+ f"/api/reports/{report['id']}/resolve", json={"note": "Issue addressed and inspected"}
+ )
+ self.assertEqual(self.client.patch(endpoint, json={"archived": True}).status_code, 200)
+ self.assertNotIn(self.asset["id"], [a["id"] for a in self.client.get("/api/assets").json()])
+ self.assertEqual(
+ self.client.get("/api/assets?archived=true").json()[0]["id"], self.asset["id"]
+ )
+ self.assertEqual(self.upload().status_code, 404)
+ self.client.patch(endpoint, json={"archived": False})
+ self.assertIn(self.asset["id"], [a["id"] for a in self.client.get("/api/assets").json()])
+
+ def test_verified_image_and_rejected_invalid_uploads(self):
+ for kwargs, code in [
({"description": " "}, 422),
({"file": ("test.txt", b"text", "text/plain")}, 415),
({"file": ("empty.png", b"", "image/png")}, 422),
- ({"file": ("large.png", b"x" * (5 * 1024 * 1024 + 1), "image/png")}, 413),
+ ({"file": ("fake.png", b"not an image", "image/png")}, 422),
+ ({"file": ("large.png", b"x" * (3 * 1024 * 1024 + 1), "image/png")}, 413),
+ ({"severity": "30"}, 422),
]:
- self.assertEqual(self.upload(**kwargs).status_code, status)
- self.assertEqual(self.client.get("/reports").json(), [])
- self.assertEqual(self.client.get("/assets").json()[0]["health_score"], self.asset["health_score"])
+ response = self.upload(**kwargs)
+ self.assertEqual(response.status_code, code, response.text)
+ self.assertEqual(self.client.get("/api/reports").json(), [])
+ self.assertEqual(self.health(), self.asset["health_score"])
+ report = self.upload().json()
+ image = self.client.get(f"/api/reports/{report['id']}/image")
+ self.assertEqual(image.status_code, 200)
+ self.assertEqual(image.headers["content-type"], "image/jpeg")
+ with Image.open(io.BytesIO(image.content)) as decoded:
+ self.assertEqual(decoded.size, (40, 40))
+ self.assertFalse(decoded.getexif())
+
+ def test_public_report_is_scoped_and_tracking_does_not_leak_details(self):
+ public = TestClient(app, headers=HEADERS)
+ token = self.account["share_token"]
+ public_assets = public.get(f"/api/public/{token}/assets").json()
+ self.assertEqual(set(public_assets["assets"][0]), {"id", "name", "asset_type"})
+ response = public.post(
+ f"/api/public/{token}/reports",
+ data={
+ "asset_id": self.asset["id"],
+ "description": "Private observation",
+ "severity": "5",
+ },
+ )
+ self.assertEqual(response.status_code, 201, response.text)
+ report = response.json()
+ tracking = public.get(f"/api/track/{report['tracking_code']}").json()
+ self.assertEqual(tracking["status"], "Open")
+ self.assertNotIn("description", tracking)
+ self.assertNotIn("image", tracking)
+ self.assertNotIn("username", tracking)
+ foreign = self.other.get("/api/assets").json()[0]["id"]
+ self.assertEqual(
+ public.post(
+ f"/api/public/{token}/reports",
+ data={"asset_id": foreign, "description": "Wrong workspace", "severity": "5"},
+ ).status_code,
+ 404,
+ )
+ self.assertEqual(public.get("/api/public/not-a-token/assets").status_code, 404)
+
+ def test_flood_is_reversible_idempotent_and_workspace_scoped(self):
+ before = {a["id"]: a["health_score"] for a in self.client.get("/api/assets").json()}
+ second_before = self.other.get("/api/assets").json()
+ endpoint = "/api/scenarios/flood"
+ self.assertTrue(self.client.post(endpoint, json={"active": True}).json()["changed"])
+ first = self.client.get("/api/assets").json()
+ self.assertFalse(self.client.post(endpoint, json={"active": True}).json()["changed"])
+ self.assertEqual(first, self.client.get("/api/assets").json())
+ for asset in first:
+ self.assertEqual(
+ asset["health_score"],
+ max(0, before[asset["id"]] - (15 if asset["asset_type"] == "Road" else 0)),
+ )
+ self.client.post(endpoint, json={"active": False})
+ self.assertEqual(
+ before, {a["id"]: a["health_score"] for a in self.client.get("/api/assets").json()}
+ )
+ self.assertEqual(second_before, self.other.get("/api/assets").json())
- def test_seeding_is_post_only_and_repeatable(self):
+ def test_maintenance_records_note_and_keeps_open_report_penalty(self):
+ self.upload()
+ endpoint = f"/api/assets/{self.asset['id']}/maintenance"
+ response = self.client.post(endpoint, json={"note": "Repaired deck expansion joints"})
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(response.json()["condition_score"], 100)
+ self.assertEqual(response.json()["health_score"], 85)
+ self.assertTrue(response.json()["last_service_at"])
+ self.assertIn(
+ "Repaired deck expansion joints", self.client.get("/api/activity").json()[0]["message"]
+ )
+ self.assertEqual(self.client.post(endpoint, json={"note": " "}).status_code, 422)
+
+ def test_edit_asset_validation_seed_and_filters(self):
+ endpoint = f"/api/assets/{self.asset['id']}"
+ self.assertEqual(
+ self.client.put(endpoint, json={**ASSET, "name": "New name", "latitude": 22}).json()[
+ "latitude"
+ ],
+ 22,
+ )
+ self.assertEqual(
+ self.client.put(endpoint, json={**ASSET, "construction_year": 9999}).status_code, 422
+ )
+ self.assertEqual(self.client.post("/api/setup-demo").json()["added"], 0)
+ self.assertEqual(self.client.post("/api/setup-demo").json()["added"], 0)
+ self.assertEqual(self.client.get("/api/reports?status=bad").status_code, 422)
self.assertEqual(self.client.get("/setup-demo").status_code, 405)
- self.assertEqual(self.client.post("/setup-demo").status_code, 200)
- self.client.post("/setup-demo")
- self.assertEqual(len(self.client.get("/assets").json()), 10)
- def test_invalid_report_filter_and_missing_report(self):
- self.assertEqual(self.client.get("/reports?status=invalid").status_code, 422)
- self.assertEqual(self.client.post("/reports/999/resolve").status_code, 404)
+ def test_export_is_scoped_and_protects_spreadsheet_formulas(self):
+ self.client.put(
+ f"/api/assets/{self.asset['id']}",
+ json={**ASSET, "name": '=HYPERLINK("https://example.com")'},
+ )
+ response = self.client.get("/api/export")
+ self.assertEqual(response.status_code, 200)
+ rows = list(csv.reader(io.StringIO(response.text)))
+ self.assertEqual(len(rows), 11)
+ self.assertTrue(rows[-1][1].startswith("'="))
+ self.assertNotIn(self.other_account["share_token"], response.text)
+
+ def test_session_revocation_expiry_and_persistence_across_clients(self):
+ resumed = TestClient(app, headers=HEADERS)
+ resumed.cookies.update(self.client.cookies)
+ self.assertEqual(len(resumed.get("/api/assets").json()), 10)
+ resumed.post("/api/auth/logout")
+ self.assertEqual(self.client.get("/api/assets").status_code, 401)
+ with database.SessionLocal() as db:
+ db.query(database.LoginSession).update({"expires_at": database.utcnow()})
+ db.commit()
+ self.assertEqual(self.other.get("/api/auth/me").status_code, 401)
+
+ def test_auth_request_limit(self):
+ for _ in range(15):
+ self.assertEqual(
+ self.client.post(
+ "/api/auth/login",
+ json={"username": "missing", "password": "incorrect password"},
+ ).status_code,
+ 401,
+ )
+ response = self.client.post(
+ "/api/auth/login", json={"username": "missing", "password": "incorrect password"}
+ )
+ self.assertEqual(response.status_code, 429)
+ self.assertIn("retry-after", response.headers)
+
+ def test_expired_demo_is_inaccessible_and_cleaned_up(self):
+ from datetime import timedelta
+
+ with database.SessionLocal() as db:
+ db.query(database.Workspace).filter_by(share_token=self.account["share_token"]).update(
+ {"created_at": database.utcnow() - timedelta(days=8)}
+ )
+ db.commit()
+ self.assertEqual(self.client.get("/api/auth/me").status_code, 401)
+ self.assertEqual(
+ self.client.get(f"/api/public/{self.account['share_token']}/assets").status_code, 404
+ )
+ self.client.post("/api/auth/demo")
+ with database.SessionLocal() as db:
+ self.assertIsNone(
+ db.query(database.Workspace)
+ .filter_by(share_token=self.account["share_token"])
+ .first()
+ )
+
+ def test_pages_health_and_secret_files_are_not_served(self):
+ self.assertEqual(self.client.get("/api/health").json()["status"], "ok")
+ for path in ["/", "/app", "/report/example", "/track/example"]:
+ self.assertEqual(self.client.get(path).status_code, 200)
+ self.assertEqual(
+ self.client.get("/auth.html", follow_redirects=False).headers["location"], "/app"
+ )
+ self.assertEqual(self.client.get("/database.py").status_code, 404)
+ self.assertEqual(self.client.get("/structiq.db").status_code, 404)
+ self.assertEqual(self.client.get("/.env").status_code, 404)
+ self.assertEqual(self.client.get("/api/assets").headers["cache-control"], "no-store")
diff --git a/tests/test_schemas.py b/tests/test_schemas.py
index fc38d65..76a8a5b 100644
--- a/tests/test_schemas.py
+++ b/tests/test_schemas.py
@@ -4,7 +4,6 @@
from schemas import AssetCreate
-
VALID_ASSET = {
"name": "Adyar Bridge",
"asset_type": "Bridge",
diff --git a/vercel.json b/vercel.json
new file mode 100644
index 0000000..f539b60
--- /dev/null
+++ b/vercel.json
@@ -0,0 +1,5 @@
+{
+ "$schema": "https://openapi.vercel.sh/vercel.json",
+ "framework": "fastapi",
+ "regions": ["sin1"]
+}