class_name EventTimeline extends CanvasLayer ## Body-camera timeline for the shift review (PLAN.md: Evidence and Body Camera). ## First version is a text timeline of the structured incident events ADR-0005 ## already stores: step through them with the arrow keys or the buttons. No live ## 3D replay. signal closed() const Strings := preload("res://src/core/strings.gd") var events: Array[Dictionary] = [] var index := 0 var _list_label: Label = null var _detail_label: Label = null var _counter_label: Label = null var _root: Control = null func _init() -> void: name = "EventTimeline" layer = 32 _build() hide() func _build() -> void: _root = Control.new() _root.name = "TimelineRoot" _root.set_anchors_preset(Control.PRESET_FULL_RECT) _root.mouse_filter = Control.MOUSE_FILTER_STOP add_child(_root) var dim := ColorRect.new() dim.color = Color(0.02, 0.03, 0.05, 0.9) dim.set_anchors_preset(Control.PRESET_FULL_RECT) dim.mouse_filter = Control.MOUSE_FILTER_IGNORE _root.add_child(dim) var center := CenterContainer.new() center.set_anchors_preset(Control.PRESET_FULL_RECT) _root.add_child(center) var column := VBoxContainer.new() column.custom_minimum_size = Vector2(760, 0) column.add_theme_constant_override("separation", 8) center.add_child(column) var title := Label.new() title.text = Strings.lookup("timeline.title") title.add_theme_font_size_override("font_size", 26) title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER column.add_child(title) _counter_label = Label.new() _counter_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER _counter_label.add_theme_color_override("font_color", Color(0.72, 0.78, 0.9)) column.add_child(_counter_label) _detail_label = Label.new() _detail_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER _detail_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART _detail_label.custom_minimum_size = Vector2(760, 48) _detail_label.add_theme_font_size_override("font_size", 18) column.add_child(_detail_label) _list_label = Label.new() _list_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT _list_label.autowrap_mode = TextServer.AUTOWRAP_OFF _list_label.custom_minimum_size = Vector2(760, 0) _list_label.add_theme_font_size_override("font_size", 13) _list_label.add_theme_color_override("font_color", Color(0.8, 0.84, 0.9)) column.add_child(_list_label) var row := HBoxContainer.new() row.alignment = BoxContainer.ALIGNMENT_CENTER row.add_theme_constant_override("separation", 10) column.add_child(row) var prev := Button.new() prev.name = "PrevButton" prev.text = Strings.lookup("timeline.prev") prev.pressed.connect(func(): step(-1)) row.add_child(prev) var next := Button.new() next.name = "NextButton" next.text = Strings.lookup("timeline.next") next.pressed.connect(func(): step(1)) row.add_child(next) var close := Button.new() close.name = "CloseButton" close.text = Strings.lookup("timeline.close") close.pressed.connect(_on_close) row.add_child(close) var hint := Label.new() hint.text = Strings.lookup("timeline.hint") hint.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER hint.add_theme_color_override("font_color", Color(0.55, 0.58, 0.65)) column.add_child(hint) ## Flatten every incident's event stream into one time-ordered list. func flatten(review: Dictionary) -> Array[Dictionary]: var out: Array[Dictionary] = [] var incidents: Dictionary = review.get("incidents", {}) var list: Array = incidents.get("incidents", []) for incident in list: var incident_id := str(incident.get("id", "")) var npc_id := str(incident.get("npc_id", "")) for event in incident.get("events", []): var row: Dictionary = (event as Dictionary).duplicate() row["incident_id"] = incident_id row["npc_id"] = npc_id out.append(row) out.sort_custom(func(a, b): return int(a.get("at_ms", 0)) < int(b.get("at_ms", 0))) return out func show_timeline(review: Dictionary) -> void: events = flatten(review) index = 0 visible = true _root.visible = true Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE) _refresh() func step(direction: int) -> void: if events.is_empty(): return index = clampi(index + direction, 0, events.size() - 1) _refresh() func close() -> void: visible = false _root.visible = false closed.emit() func _on_close() -> void: close() func _refresh() -> void: if events.is_empty(): _counter_label.text = Strings.lookup("timeline.empty") _detail_label.text = "" _list_label.text = "" return var event: Dictionary = events[index] _counter_label.text = Strings.fmt("timeline.position", [index + 1, events.size()]) _detail_label.text = describe(event) var lines: Array[String] = [] var from := maxi(0, index - 6) var to := mini(events.size(), from + 13) for i in range(from, to): var marker := ">" if i == index else " " lines.append("%s %s" % [marker, describe(events[i])]) _list_label.text = "\n".join(lines) ## One-line summary of a recorded event. Public so it is headless-testable. func describe(event: Dictionary) -> String: var type := str(event.get("type", "event")) var npc := str(event.get("npc_id", "")) var stamp := _stamp(int(event.get("at_ms", 0))) var detail := "" match type: "player_action": detail = "%s (L%d)" % [str(event.get("action", "")), int(event.get("level", 0))] "access_check": var evidence: Dictionary = event.get("evidence", {}) detail = "decided %s (verified %s)" % [ "allow" if bool(evidence.get("decided", false)) else "deny", "yes" if bool(evidence.get("verified_granted", false)) else "no", ] "recognition_decision": detail = "recognized" "reason_requested": var reply := str(event.get("reply", "")) detail = "reason: \"%s\"" % (reply if reply != "" else "no answer") "body_camera": detail = "body camera %s (%s)" % [ "on" if bool(event.get("on", false)) else "off", str(event.get("reason", "")), ] "responder_action": detail = "%s response: %s" % [str(event.get("call_type", "assist")), str(event.get("outcome", ""))] "evacuation": detail = "evacuated" "investigation_opened": detail = "investigation: %s" % str(event.get("reason", "")) _: detail = type return "%s %s %s" % [stamp, npc, detail] func _stamp(at_ms: int) -> String: if at_ms <= 0: return "--:--" var total := at_ms / 1000 return "%02d:%02d" % [total / 60, total % 60] func _unhandled_input(event: InputEvent) -> void: if not visible: return if event is InputEventKey and event.pressed and not event.echo: match event.keycode: KEY_LEFT, KEY_A: step(-1) get_viewport().set_input_as_handled() KEY_RIGHT, KEY_D: step(1) get_viewport().set_input_as_handled() KEY_ESCAPE: close() get_viewport().set_input_as_handled()