diff --git a/_maps/map_files/Birdshot/birdshot.dmm b/_maps/map_files/Birdshot/birdshot.dmm
index 00961dbd7f5d6..4c8c4c26b3850 100644
--- a/_maps/map_files/Birdshot/birdshot.dmm
+++ b/_maps/map_files/Birdshot/birdshot.dmm
@@ -53177,6 +53177,7 @@
name = "Detective Requests Console"
},
/obj/machinery/light/small/directional/west,
+/obj/structure/detectiveboard/directional/west,
/turf/open/floor/wood,
/area/station/security/detectives_office)
"smf" = (
diff --git a/_maps/map_files/IceBoxStation/IceBoxStation.dmm b/_maps/map_files/IceBoxStation/IceBoxStation.dmm
index b88647d4934a0..45240e02a46fc 100644
--- a/_maps/map_files/IceBoxStation/IceBoxStation.dmm
+++ b/_maps/map_files/IceBoxStation/IceBoxStation.dmm
@@ -73744,6 +73744,7 @@
pixel_x = -5
},
/obj/structure/cable,
+/obj/structure/detectiveboard/directional/west,
/turf/open/floor/carpet,
/area/station/security/detectives_office)
"vZS" = (
diff --git a/_maps/map_files/MetaStation/MetaStation.dmm b/_maps/map_files/MetaStation/MetaStation.dmm
index a719ae4c67114..e6e91285c4f21 100644
--- a/_maps/map_files/MetaStation/MetaStation.dmm
+++ b/_maps/map_files/MetaStation/MetaStation.dmm
@@ -20128,7 +20128,6 @@
/area/station/cargo/sorting)
"hlj" = (
/obj/structure/table/wood,
-/obj/structure/secure_safe/directional/east,
/obj/machinery/computer/security/wooden_tv{
pixel_x = 3;
pixel_y = 2
@@ -20138,6 +20137,7 @@
name = "detective's office shutters control";
req_access = list("detective")
},
+/obj/structure/detectiveboard/directional/east,
/turf/open/floor/carpet,
/area/station/security/detectives_office)
"hlq" = (
@@ -50063,6 +50063,11 @@
},
/turf/open/floor/iron,
/area/station/engineering/break_room)
+"rJh" = (
+/obj/structure/window/reinforced/spawner/directional/north,
+/obj/structure/secure_safe/directional/west,
+/turf/open/floor/iron/grimy,
+/area/station/security/detectives_office)
"rJk" = (
/obj/machinery/door/airlock{
name = "Theater Backstage"
@@ -99518,7 +99523,7 @@ sWV
sWV
sWV
qJb
-fEn
+rJh
hME
olw
olw
diff --git a/_maps/map_files/NorthStar/north_star.dmm b/_maps/map_files/NorthStar/north_star.dmm
index b4168c7d54d93..3ecdc51485e53 100644
--- a/_maps/map_files/NorthStar/north_star.dmm
+++ b/_maps/map_files/NorthStar/north_star.dmm
@@ -34444,6 +34444,7 @@
dir = 8
},
/obj/item/radio/intercom/directional/north,
+/obj/structure/detectiveboard/directional/east,
/turf/open/floor/carpet,
/area/station/security/detectives_office)
"iWW" = (
diff --git a/_maps/map_files/tramstation/tramstation.dmm b/_maps/map_files/tramstation/tramstation.dmm
index 469e5ec80fb3d..a7d42345cac2b 100644
--- a/_maps/map_files/tramstation/tramstation.dmm
+++ b/_maps/map_files/tramstation/tramstation.dmm
@@ -44474,8 +44474,8 @@
/turf/closed/wall/r_wall,
/area/station/engineering/supermatter)
"oMh" = (
-/obj/structure/secure_safe/directional/east,
/obj/machinery/airalarm/directional/north,
+/obj/structure/detectiveboard/directional/east,
/turf/open/floor/carpet,
/area/station/security/detectives_office)
"oMz" = (
@@ -59837,6 +59837,7 @@
pixel_x = -23;
pixel_y = 8
},
+/obj/structure/secure_safe/directional/south,
/turf/open/floor/iron/grimy,
/area/station/security/detectives_office)
"udZ" = (
diff --git a/_maps/map_files/wawastation/wawastation.dmm b/_maps/map_files/wawastation/wawastation.dmm
index fa4ea9e4b3263..36496a0ba7441 100644
--- a/_maps/map_files/wawastation/wawastation.dmm
+++ b/_maps/map_files/wawastation/wawastation.dmm
@@ -8832,6 +8832,7 @@
"diI" = (
/obj/machinery/light/small/directional/north,
/obj/machinery/computer/security,
+/obj/structure/detectiveboard/directional/north,
/turf/open/floor/carpet,
/area/station/security/detectives_office)
"diM" = (
diff --git a/code/game/objects/structures/detectiveboard.dm b/code/game/objects/structures/detectiveboard.dm
new file mode 100644
index 0000000000000..c94cdf1c7775c
--- /dev/null
+++ b/code/game/objects/structures/detectiveboard.dm
@@ -0,0 +1,303 @@
+#define MAX_ICON_NOTICES 8
+#define MAX_CASES 8
+#define MAX_EVIDENCE_Y 3500
+#define MAX_EVIDENCE_X 1180
+
+#define EVIDENCE_TYPE_PHOTO "photo"
+#define EVIDENCE_TYPE_PAPER "paper"
+
+/obj/structure/detectiveboard
+ name = "detective notice board"
+ desc = "A board for linking evidence to crimes."
+ icon = 'icons/obj/wallmounts.dmi'
+ icon_state = "noticeboard"
+ density = FALSE
+ anchored = TRUE
+ max_integrity = 150
+
+ /// When player attaching evidence to board this will become TRUE
+ var/attaching_evidence = FALSE
+ /// Colors for case color
+ var/list/case_colors = list("red", "orange", "yellow", "green", "blue", "violet")
+ /// List of board cases
+ var/list/datum/case/cases = list()
+ /// Index of viewing case in cases array
+ var/current_case = 1
+
+MAPPING_DIRECTIONAL_HELPERS(/obj/structure/detectiveboard, 32)
+
+/obj/structure/detectiveboard/Initialize(mapload)
+ . = ..()
+
+ if(!mapload)
+ return
+ for(var/obj/item/item in loc)
+ if(istype(item, /obj/item/paper) || istype(item, /obj/item/photo))
+ item.forceMove(src)
+ cases[current_case].notices++
+
+/// Attaching evidences: photo and papers
+
+/obj/structure/detectiveboard/attackby(obj/item/item, mob/user, params)
+ if(!cases.len)
+ to_chat(user, "There are no cases!")
+ return
+ if(istype(item, /obj/item/paper) || istype(item, /obj/item/photo))
+ if(attaching_evidence)
+ to_chat(user, "You already attaching evidence!")
+ return
+ attaching_evidence = TRUE
+ var/name = tgui_input_text(user, "Please enter the evidence name", "Detective's Board")
+ if(!name)
+ attaching_evidence = FALSE
+ return
+ var/desc = tgui_input_text(user, "Please enter the evidence description", "Detective's Board")
+ if(!desc)
+ attaching_evidence = FALSE
+ return
+
+ if(!user.transferItemToLoc(item, src))
+ attaching_evidence = FALSE
+ return
+ cases[current_case].notices++
+ var/datum/evidence/evidence = new (name, desc, item)
+ cases[current_case].evidences += evidence
+ to_chat(user, span_notice("You pin the [item] to the detective board."))
+ attaching_evidence = FALSE
+ update_appearance(UPDATE_ICON)
+ return
+ return ..()
+
+/obj/structure/detectiveboard/wrench_act_secondary(mob/living/user, obj/item/tool)
+ . = ..()
+ balloon_alert(user, "[anchored ? "un" : ""]securing...")
+ tool.play_tool_sound(src)
+ if(tool.use_tool(src, user, 6 SECONDS))
+ playsound(loc, 'sound/items/deconstruct.ogg', 50, TRUE)
+ balloon_alert(user, "[anchored ? "un" : ""]secured")
+ deconstruct()
+ return TRUE
+
+/obj/structure/detectiveboard/ui_state(mob/user)
+ return GLOB.physical_state
+
+/obj/structure/detectiveboard/ui_interact(mob/user, datum/tgui/ui)
+ ui = SStgui.try_update_ui(user, src, ui)
+ if(!ui)
+ ui = new(user, src, "DetectiveBoard", name, 1200, 800)
+ ui.open()
+
+/obj/structure/detectiveboard/ui_data(mob/user)
+ var/list/data = list()
+ var/list/data_cases = list()
+ for(var/datum/case/case in cases)
+ var/list/data_case = list("ref"=REF(case),"name" = case.name, "color" = case.color)
+ var/list/data_evidences = list()
+ for(var/datum/evidence/evidence in case.evidences)
+ var/list/data_evidence = list("ref" = REF(evidence), "name" = evidence.name, "type" = evidence.evidence_type, "description" = evidence.description, "x"=evidence.x, "y"=evidence.y)
+ var/list/data_connections = list()
+ for(var/datum/evidence/connection in evidence.connections)
+ data_connections += REF(connection) // TODO: create array of strings
+ data_evidence["connections"] = data_connections
+ switch(evidence.evidence_type)
+ if(EVIDENCE_TYPE_PHOTO)
+ var/obj/item/photo/photo = evidence.item
+ var/tmp_picture_name = "evidence_photo[REF(photo)].png"
+ user << browse_rsc(photo.picture.picture_image, tmp_picture_name)
+ data_evidence["photo_url"] = tmp_picture_name
+ if(EVIDENCE_TYPE_PAPER)
+ var/obj/item/paper/paper = evidence.item
+ data_evidence["text"] = ""
+ if(paper.raw_text_inputs && paper.raw_text_inputs.len)
+ data_evidence["text"] = paper.raw_text_inputs[1].raw_text
+ data_evidences += list(data_evidence)
+ data_case["evidences"] = data_evidences
+ var/list/connections = list()
+ for(var/datum/evidence/evidence in case.evidences)
+ for(var/datum/evidence/connection in evidence.connections)
+ var/list/from_pos = get_pin_position(evidence)
+ var/list/to_pos = get_pin_position(connection)
+ var/found_in_connections = FALSE
+ for(var/list/con in connections)
+ if(con["from"]["x"] == to_pos["x"] && con["from"]["y"] == to_pos["y"] && con["to"]["x"] == from_pos["x"] && con["to"]["y"] == from_pos["y"] )
+ found_in_connections = TRUE
+ if(!found_in_connections)
+ var/list/data_connection = list("color" = "red", "from" = from_pos, "to" = to_pos)
+ connections += list(data_connection)
+ data_case["connections"] = connections
+ data_cases += list(data_case)
+
+ data["cases"] = data_cases
+ data["current_case"] = current_case
+ return data
+
+/obj/structure/detectiveboard/proc/get_pin_position(datum/evidence/evidence)
+ return list("x" = evidence.x + 15, "y" = evidence.y + 15)
+
+/obj/structure/detectiveboard/ui_act(action, params, datum/tgui/ui, datum/ui_state/state)
+ . = ..()
+ if(.)
+ return
+ var/mob/user = ui.user
+ switch(action)
+ if("add_case")
+ if(cases.len == MAX_CASES)
+ return FALSE
+ var/new_case = tgui_input_text(user, "Please enter the case name", "Detective's Board")
+ if(!new_case)
+ return FALSE
+ var/case_color = tgui_input_list(user, "Please choose case color", "Detective's Board", case_colors)
+ if(!case_color)
+ return FALSE
+
+ var/datum/case/case = new (new_case, case_color)
+ cases += case
+ current_case = clamp(cases.len, 1, MAX_CASES)
+ update_appearance(UPDATE_ICON)
+ return TRUE
+ if("set_case")
+ if(cases && params["case"] && params["case"] <= cases.len)
+ current_case = clamp(params["case"], 1, MAX_CASES)
+ update_appearance(UPDATE_ICON)
+ return TRUE
+ if("remove_case")
+ var/datum/case/case = locate(params["case_ref"]) in cases
+ if(case)
+ for(var/datum/evidence/evidence in case.evidences)
+ remove_item(evidence.item, user)
+ cases -= case
+ current_case = clamp(cases.len, 1, MAX_CASES)
+ update_appearance(UPDATE_ICON)
+ return TRUE
+ if("rename_case")
+ var/new_name = tgui_input_text(user, "Please ender the case new name", "Detective's Board")
+ if(new_name)
+ var/datum/case/case = locate(params["case_ref"]) in cases
+ case.name = new_name
+ return TRUE
+ if("look_evidence")
+ var/datum/case/case = locate(params["case_ref"]) in cases
+ var/datum/evidence/evidence = locate(params["evidence_ref"]) in case.evidences
+ if(evidence.evidence_type == EVIDENCE_TYPE_PHOTO)
+ var/obj/item/photo/item = evidence.item
+ item.show(user)
+ return TRUE
+
+ var/obj/item/paper/paper = evidence.item
+ var/paper_text = ""
+ for(var/datum/paper_input/text_input as anything in paper.raw_text_inputs)
+ paper_text += text_input.raw_text
+ user << browse("
[paper.name]" \
+ + "" \
+ + "[paper_text]" \
+ + "", "window=photo_showing;size=480x608")
+ onclose(user, "[name]")
+ if("remove_evidence")
+ var/datum/case/case = cases[current_case]
+ var/datum/evidence/evidence = locate(params["evidence_ref"]) in case.evidences
+ if(evidence)
+ var/obj/item/item = evidence.item
+ if(!istype(item) || item.loc != src)
+ return
+ remove_item(item, user)
+ for(var/datum/evidence/connection in evidence.connections)
+ connection.connections.Remove(evidence)
+ case.evidences -= evidence
+ update_appearance(UPDATE_ICON)
+ return TRUE
+ if("set_evidence_cords")
+ var/datum/case/case = locate(params["case_ref"]) in cases
+ if(case)
+ var/datum/evidence/evidence = locate(params["evidence_ref"]) in case.evidences
+ if(evidence)
+ evidence.x = clamp(params["rel_x"], 0, MAX_EVIDENCE_X)
+ evidence.y = clamp(params["rel_y"], 0, MAX_EVIDENCE_Y)
+ return TRUE
+ if("add_connection")
+ var/datum/evidence/from_evidence = locate(params["from_ref"]) in cases[current_case].evidences
+ var/datum/evidence/to_evidence = locate(params["to_ref"]) in cases[current_case].evidences
+ if(from_evidence && to_evidence)
+ from_evidence.connections += to_evidence
+ to_evidence.connections += from_evidence
+ return TRUE
+
+
+ return FALSE
+
+/obj/structure/detectiveboard/update_overlays()
+ . = ..()
+ if(cases[current_case].notices < MAX_ICON_NOTICES)
+ . += "notices_[cases[current_case].notices]"
+ else
+ . += "notices_[MAX_ICON_NOTICES]"
+/**
+ * Removes an item from the notice board
+ *
+ * Arguments:
+ * * item - The item that is to be removed
+ * * user - The mob that is trying to get the item removed, if there is one
+ */
+/obj/structure/detectiveboard/proc/remove_item(obj/item/item, mob/user)
+ item.forceMove(drop_location())
+ if(user)
+ user.put_in_hands(item)
+ balloon_alert(user, "removed from board")
+ cases[current_case].notices--
+ update_appearance(UPDATE_ICON)
+
+/obj/structure/detectiveboard/atom_deconstruct(disassembled = TRUE)
+ if(!disassembled)
+ new /obj/item/stack/sheet/mineral/wood(loc)
+ else
+ new /obj/item/wallframe/detectiveboard(loc)
+ for(var/obj/item/content in contents)
+ remove_item(content)
+
+/obj/item/wallframe/detectiveboard
+ name = "detective notice board"
+ desc = "A board for linking evidence to crimes."
+ icon = 'icons/obj/wallmounts.dmi'
+ icon_state = "noticeboard"
+ custom_materials = list(
+ /datum/material/wood = SHEET_MATERIAL_AMOUNT,
+ )
+ resistance_flags = FLAMMABLE
+ result_path = /obj/structure/detectiveboard
+ pixel_shift = 32
+
+/datum/evidence
+ var/name = "None"
+ var/description = "No description"
+ var/evidence_type = "none"
+ var/x = 0
+ var/y = 0
+ var/list/datum/evidence/connections = list()
+ var/obj/item/item = null
+
+/datum/evidence/New(param_name, param_desc, obj/item/param_item)
+ name = param_name
+ description = param_desc
+ item = param_item
+ if(istype(param_item, /obj/item/photo))
+ evidence_type = EVIDENCE_TYPE_PHOTO
+ else
+ evidence_type = EVIDENCE_TYPE_PAPER
+
+/datum/case
+ var/notices = 0
+ var/name = ""
+ var/color = 0
+ var/list/datum/evidence/evidences = list()
+
+/datum/case/New(param_name, param_color)
+ name = param_name
+ color = param_color
+
+
+#undef EVIDENCE_TYPE_PHOTO
+#undef EVIDENCE_TYPE_PAPER
+
+#undef MAX_EVIDENCE_Y
+#undef MAX_EVIDENCE_X
+#undef MAX_ICON_NOTICES
+#undef MAX_CASES
diff --git a/code/modules/cargo/packs/security.dm b/code/modules/cargo/packs/security.dm
index 05360fe913f0a..440df83a020ac 100644
--- a/code/modules/cargo/packs/security.dm
+++ b/code/modules/cargo/packs/security.dm
@@ -36,7 +36,7 @@
/datum/supply_pack/security/forensics
name = "Forensics Crate"
desc = "Stay hot on the criminal's heels with Nanotrasen's Detective Essentials™. \
- Contains a forensics scanner, six evidence bags, camera, tape recorder, white crayon, \
+ Contains a forensics scanner, six evidence bags, camera, special board for evidences, tape recorder, stick of chalk, \
and of course, a fedora."
cost = CARGO_CRATE_VALUE * 2.5
access_view = ACCESS_MORGUE
@@ -46,6 +46,7 @@
/obj/item/taperecorder,
/obj/item/toy/crayon/white,
/obj/item/clothing/head/fedora/det_hat,
+ /obj/item/wallframe/detectiveboard,
)
crate_name = "forensics crate"
diff --git a/tgstation.dme b/tgstation.dme
index 62e3dad0ed50b..e0b48109189ec 100644
--- a/tgstation.dme
+++ b/tgstation.dme
@@ -2678,6 +2678,7 @@
#include "code\game\objects\structures\curtains.dm"
#include "code\game\objects\structures\deployable_turret.dm"
#include "code\game\objects\structures\destructible_structures.dm"
+#include "code\game\objects\structures\detectiveboard.dm"
#include "code\game\objects\structures\displaycase.dm"
#include "code\game\objects\structures\divine.dm"
#include "code\game\objects\structures\door_assembly.dm"
diff --git a/tgui/packages/tgui/interfaces/DetectiveBoard/BoardTabs.tsx b/tgui/packages/tgui/interfaces/DetectiveBoard/BoardTabs.tsx
new file mode 100644
index 0000000000000..4300cfade9895
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/DetectiveBoard/BoardTabs.tsx
@@ -0,0 +1,63 @@
+import { classes } from '../../../common/react';
+import { useBackend } from '../../backend';
+import { Box, Button } from '../../components';
+import { DataCase } from './DataTypes';
+
+type BoardTabsData = {
+ cases: DataCase[];
+ current_case: number;
+};
+
+const BoardTab = (props) => {
+ const { color, selected, onClick = () => {}, children } = props;
+ return (
+
+ {children}
+
+ );
+};
+
+export const BoardTabs = (props) => {
+ const { act, data } = useBackend();
+ const { cases, current_case } = data;
+ return (
+
+ {cases?.map((item, index) => (
+ act('set_case', { case: index + 1 })}
+ >
+ {item.name}
+ {current_case - 1 === index && (
+ <>
+
+ ))}
+ act('add_case')} />
+
+ );
+};
diff --git a/tgui/packages/tgui/interfaces/DetectiveBoard/DataTypes.tsx b/tgui/packages/tgui/interfaces/DetectiveBoard/DataTypes.tsx
new file mode 100644
index 0000000000000..712243efffda6
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/DetectiveBoard/DataTypes.tsx
@@ -0,0 +1,21 @@
+import { Connection } from '../common/Connections';
+
+export type DataCase = {
+ ref: string;
+ name: string;
+ color: string;
+ evidences: DataEvidence[];
+ connections: Connection[];
+};
+
+export type DataEvidence = {
+ ref: string;
+ name: string;
+ description: string;
+ type: string;
+ x: number;
+ y: number;
+ photo_url: string;
+ text: string;
+ connections: string[];
+};
diff --git a/tgui/packages/tgui/interfaces/DetectiveBoard/Evidence.tsx b/tgui/packages/tgui/interfaces/DetectiveBoard/Evidence.tsx
new file mode 100644
index 0000000000000..05c448bb7894d
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/DetectiveBoard/Evidence.tsx
@@ -0,0 +1,181 @@
+import { useEffect, useState } from 'react';
+
+import { Box, Button, Flex, Stack } from '../../components';
+import { DataEvidence } from './DataTypes';
+import { Pin } from './Pin';
+
+type EvidenceProps = {
+ case_ref: string;
+ evidence: DataEvidence;
+ act: Function;
+ onPinStartConnecting: Function;
+ onPinConnected: Function;
+ onPinMouseUp: Function;
+ onEvidenceRemoved: Function;
+ onStartMoving: Function;
+ onStopMoving: Function;
+ onMoving: Function;
+};
+
+type Position = {
+ x: number;
+ y: number;
+};
+
+export function Evidence(props: EvidenceProps) {
+ const { evidence, case_ref, act } = props;
+
+ const [dragging, setDragging] = useState(false);
+
+ const [canDrag, setCanDrag] = useState(true);
+
+ const [dragPosition, setDragPosition] = useState({
+ x: evidence.x,
+ y: evidence.y,
+ });
+
+ const [lastMousePosition, setLastMousePosition] = useState(
+ null,
+ );
+
+ function handleMouseDown(args) {
+ if (canDrag) {
+ setDragging(true);
+ props.onStartMoving(evidence);
+ setLastMousePosition({ x: args.screenX, y: args.screenY });
+ }
+ }
+
+ useEffect(() => {
+ if (!dragging) {
+ return;
+ }
+
+ const handleMouseUp = (args: MouseEvent) => {
+ if (canDrag && dragPosition && dragging && lastMousePosition) {
+ act('set_evidence_cords', {
+ evidence_ref: evidence.ref,
+ case_ref: case_ref,
+ rel_x: dragPosition.x - (lastMousePosition.x - args.screenX),
+ rel_y: dragPosition.y - (lastMousePosition.y - args.screenY),
+ });
+ props.onStopMoving({
+ ...evidence,
+ y: dragPosition.y - (lastMousePosition.y - args.screenY),
+ x: dragPosition.x - (lastMousePosition.x - args.screenX),
+ });
+ }
+ setDragging(false);
+ setLastMousePosition(null);
+ };
+ window.addEventListener('mouseup', handleMouseUp);
+ return () => {
+ window.removeEventListener('mouseup', handleMouseUp);
+ };
+ }, [dragging]);
+
+ function getPinPositionByPosition(evidence: Position) {
+ return { x: evidence.x + 15, y: evidence.y + 45 };
+ }
+ useEffect(() => {
+ if (!dragging) {
+ return;
+ }
+
+ const onMouseMove = (args: MouseEvent) => {
+ if (canDrag) {
+ if (lastMousePosition) {
+ setDragPosition({
+ x: dragPosition.x - (lastMousePosition.x - args.screenX),
+ y: dragPosition.y - (lastMousePosition.y - args.screenY),
+ });
+ props.onMoving(evidence, {
+ x: dragPosition.x - (lastMousePosition.x - args.screenX),
+ y: dragPosition.y - (lastMousePosition.y - args.screenY),
+ });
+ }
+
+ setLastMousePosition({ x: args.screenX, y: args.screenY });
+ }
+ };
+
+ window.addEventListener('mousemove', onMouseMove);
+ return () => {
+ window.removeEventListener('mousemove', onMouseMove);
+ };
+ }, [evidence.x, evidence.y, dragging]);
+
+ return (
+
+
+
+
+
+
+ {
+ setCanDrag(false);
+ props.onPinStartConnecting(evidence, mousePos);
+ }}
+ onConnected={(evidence: DataEvidence) => {
+ setCanDrag(true);
+ props.onPinConnected(evidence);
+ }}
+ onMouseUp={(evidence: DataEvidence, args) => {
+ setCanDrag(true);
+ props.onPinMouseUp(evidence, args);
+ }}
+ />
+
+
+
+ {evidence.name}
+
+
+
+ {
+ props.onEvidenceRemoved(evidence);
+ act('remove_evidence', {
+ evidence_ref: evidence.ref,
+ });
+ }}
+ onMouseDown={() => setCanDrag(false)}
+ />
+
+
+
+ act('look_evidence', {
+ case_ref: case_ref,
+ evidence_ref: evidence.ref,
+ })
+ }
+ >
+ {evidence.type === 'photo' ? (
+
+ ) : (
+ // eslint-disable-next-line react/no-danger
+
+ )}
+
+
+ {evidence.description}
+
+
+
+
+ );
+}
diff --git a/tgui/packages/tgui/interfaces/DetectiveBoard/Pin.tsx b/tgui/packages/tgui/interfaces/DetectiveBoard/Pin.tsx
new file mode 100644
index 0000000000000..42f907c2af01d
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/DetectiveBoard/Pin.tsx
@@ -0,0 +1,59 @@
+import { useEffect, useState } from 'react';
+
+import { Box, Stack } from '../../components';
+import { DataEvidence } from './DataTypes';
+
+type PinProps = {
+ evidence: DataEvidence;
+ onStartConnecting: Function;
+ onConnected: Function;
+ onMouseUp: Function;
+};
+
+export function Pin(props: PinProps) {
+ const { evidence, onStartConnecting, onConnected, onMouseUp } = props;
+ const [creatingRope, setCreatingRope] = useState(false);
+
+ function handleMouseDown(args) {
+ setCreatingRope(true);
+ onStartConnecting(evidence, {
+ x: args.clientX,
+ y: args.clientY,
+ });
+ }
+
+ useEffect(() => {
+ if (!creatingRope) {
+ return;
+ }
+ const handleMouseUp = (args: MouseEvent) => {
+ if (creatingRope) {
+ setCreatingRope(false);
+ onConnected(evidence, {
+ evidence_ref: 'not used',
+ position: {
+ x: args.clientX,
+ y: args.clientY,
+ },
+ });
+ }
+ };
+ window.addEventListener('mouseup', handleMouseUp);
+ return () => {
+ window.removeEventListener('mouseup', handleMouseUp);
+ };
+ }, [creatingRope]);
+
+ return (
+
+
+ onMouseUp(evidence, args)}
+ />
+
+
+ );
+}
diff --git a/tgui/packages/tgui/interfaces/DetectiveBoard/index.tsx b/tgui/packages/tgui/interfaces/DetectiveBoard/index.tsx
new file mode 100644
index 0000000000000..a1438d8dc7bd3
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/DetectiveBoard/index.tsx
@@ -0,0 +1,355 @@
+import { useEffect, useState } from 'react';
+
+import { useBackend } from '../../backend';
+import { Box, Button, Icon, Stack } from '../../components';
+import { Window } from '../../layouts';
+import { Connection, Connections, Position } from '../common/Connections';
+import { BoardTabs } from './BoardTabs';
+import { DataCase, DataEvidence } from './DataTypes';
+import { Evidence } from './Evidence';
+
+type Data = {
+ cases: DataCase[];
+ current_case: number;
+};
+
+type TypedConnection = {
+ type: string;
+ connection: Connection;
+};
+
+const PIN_Y_OFFSET = 15;
+
+const PIN_CONNECTING_Y_OFFSET = -60;
+
+export function DetectiveBoard(props) {
+ const { act, data } = useBackend();
+
+ const { cases, current_case } = data;
+
+ const [connectingEvidence, setConnectingEvidence] =
+ useState(null);
+
+ const [movingEvidenceConnections, setMovingEvidenceConnections] = useState<
+ TypedConnection[] | null
+ >(null);
+
+ const [connection, setConnection] = useState(null);
+
+ const [connections, setConnections] = useState(
+ current_case - 1 < cases.length ? cases[current_case - 1].connections : [],
+ );
+
+ function handlePinStartConnecting(
+ evidence: DataEvidence,
+ mousePos: Position,
+ ) {
+ setConnectingEvidence(evidence);
+ setConnection({
+ color: 'red',
+ from: getPinPosition(evidence),
+ to: { x: mousePos.x, y: mousePos.y + PIN_CONNECTING_Y_OFFSET },
+ });
+ }
+
+ function getPinPositionByPosition(evidence: Position) {
+ return { x: evidence.x + 15, y: evidence.y + PIN_Y_OFFSET };
+ }
+
+ function getPinPosition(evidence: DataEvidence) {
+ return getPinPositionByPosition({ x: evidence.x, y: evidence.y });
+ }
+
+ function handlePinConnected(evidence: DataEvidence) {
+ setConnection(null);
+ setConnectingEvidence(null);
+ }
+
+ function handleEvidenceRemoved(evidence: DataEvidence) {
+ let pinPosition = getPinPosition(evidence);
+ let new_connections: Connection[] = [];
+ for (let old_connection of connections) {
+ if (
+ (old_connection.to.x === pinPosition.x &&
+ old_connection.to.y === pinPosition.y) ||
+ (old_connection.from.x === pinPosition.x &&
+ old_connection.from.y === pinPosition.y)
+ ) {
+ continue;
+ }
+ new_connections.push(old_connection);
+ }
+ setConnections(new_connections);
+ if (movingEvidenceConnections) {
+ let new_mov_connections: TypedConnection[] = [];
+ for (let old_connection of movingEvidenceConnections) {
+ if (
+ (old_connection.connection.to.x === pinPosition.x &&
+ old_connection.connection.to.y === pinPosition.y) ||
+ (old_connection.connection.from.x === pinPosition.x &&
+ old_connection.connection.from.y === pinPosition.y)
+ ) {
+ continue;
+ }
+ new_mov_connections.push(old_connection);
+ }
+ setMovingEvidenceConnections(new_mov_connections);
+ }
+ }
+
+ useEffect(() => {
+ if (!connectingEvidence) {
+ return () => {
+ window.removeEventListener('mousemove', handleMouseMove);
+ window.removeEventListener('mouseup', handleMouseUp);
+ };
+ }
+
+ function handleMouseMove(args: MouseEvent) {
+ if (connectingEvidence) {
+ setConnection({
+ color: 'red',
+ from: getPinPosition(connectingEvidence),
+ to: { x: args.clientX, y: args.clientY - 60 },
+ });
+ }
+ }
+
+ window.addEventListener('mousemove', handleMouseMove);
+ window.addEventListener('mouseup', handleMouseUp);
+ return () => {
+ window.removeEventListener('mousemove', handleMouseMove);
+ window.removeEventListener('mouseup', handleMouseUp);
+ };
+ }, [connectingEvidence]);
+
+ useEffect(() => {
+ setConnections(
+ current_case - 1 < cases.length
+ ? cases[current_case - 1].connections
+ : [],
+ );
+ }, [current_case]);
+
+ function handleMouseUp(args: MouseEvent) {
+ if (movingEvidenceConnections && connectingEvidence) {
+ let new_connections: Connection[] = [];
+ for (let con of movingEvidenceConnections) {
+ if (con.type === 'from') {
+ new_connections.push({
+ color: con.connection.color,
+ from: getPinPosition(connectingEvidence),
+ to: con.connection.to,
+ });
+ } else {
+ new_connections.push({
+ color: con.connection.color,
+ from: con.connection.from,
+ to: getPinPosition(connectingEvidence),
+ });
+ }
+ }
+ setConnections([...connections, ...new_connections]);
+ setMovingEvidenceConnections(null);
+ }
+ }
+
+ function handleMouseUpOnPin(evidence: DataEvidence, args) {
+ if (
+ connectingEvidence &&
+ connectingEvidence.ref !== evidence.ref &&
+ !connectingEvidence.connections.includes(evidence.ref) &&
+ !evidence.connections.includes(connectingEvidence.ref)
+ ) {
+ let new_connections: Connection[] = [];
+ if (movingEvidenceConnections) {
+ for (let con of movingEvidenceConnections) {
+ if (con.type === 'from') {
+ new_connections.push({
+ color: con.connection.color,
+ from: getPinPosition(connectingEvidence),
+ to: con.connection.to,
+ });
+ } else {
+ new_connections.push({
+ color: con.connection.color,
+ from: con.connection.from,
+ to: getPinPosition(connectingEvidence),
+ });
+ }
+ }
+ }
+ setConnections([
+ ...connections,
+ ...new_connections,
+ {
+ color: 'red',
+ from: getPinPosition(connectingEvidence),
+ to: getPinPosition(evidence),
+ },
+ ]);
+ act('add_connection', {
+ from_ref: connectingEvidence.ref,
+ to_ref: evidence.ref,
+ });
+ setConnection(null);
+ setConnectingEvidence(null);
+ setMovingEvidenceConnections(null);
+ }
+ }
+
+ function handleEvidenceStartMoving(evidence: DataEvidence) {
+ let moving_connections: TypedConnection[] = [];
+ let pinPosition = getPinPosition(evidence);
+ let new_connections: Connection[] = [];
+ for (let con of connections) {
+ if (con.from.x === pinPosition.x && con.from.y === pinPosition.y) {
+ moving_connections.push({ type: 'from', connection: con });
+ } else if (con.to.x === pinPosition.x && con.to.y === pinPosition.y) {
+ moving_connections.push({ type: 'to', connection: con });
+ } else {
+ new_connections.push(con);
+ }
+ }
+ setMovingEvidenceConnections(moving_connections);
+ setConnections(new_connections);
+ }
+
+ function handleEvidenceMoving(evidence: DataEvidence, position: Position) {
+ if (movingEvidenceConnections) {
+ let new_connections: TypedConnection[] = [];
+ for (let con of movingEvidenceConnections) {
+ if (con.type === 'from') {
+ new_connections.push({
+ type: con.type,
+ connection: {
+ color: con.connection.color,
+ from: getPinPositionByPosition({ x: position.x, y: position.y }),
+ to: con.connection.to,
+ },
+ });
+ } else {
+ new_connections.push({
+ type: con.type,
+ connection: {
+ color: con.connection.color,
+ from: con.connection.from,
+ to: getPinPositionByPosition({ x: position.x, y: position.y }),
+ },
+ });
+ }
+ }
+ setMovingEvidenceConnections(new_connections);
+ }
+ }
+
+ function handleEvidenceStopMoving(evidence: DataEvidence) {
+ if (movingEvidenceConnections) {
+ let new_connections: Connection[] = [];
+ for (let con of movingEvidenceConnections) {
+ if (con.type === 'from') {
+ new_connections.push({
+ color: con.connection.color,
+ from: getPinPosition(evidence),
+ to: con.connection.to,
+ });
+ } else {
+ new_connections.push({
+ color: con.connection.color,
+ from: con.connection.from,
+ to: getPinPosition(evidence),
+ });
+ }
+ }
+ setConnections([...connections, ...new_connections]);
+ setMovingEvidenceConnections(null);
+ }
+ }
+
+ function retrieveConnections(typedConnections: TypedConnection[]) {
+ let result: Connection[] = [];
+ for (let con of typedConnections) {
+ result.push(con.connection);
+ }
+ return result;
+ }
+
+ return (
+
+
+ {cases.length > 0 ? (
+ <>
+
+
+ {cases?.map(
+ (item, i) =>
+ current_case - 1 === i && (
+
+ {movingEvidenceConnections && (
+
+ )}
+ {connection && (
+
+ )}
+
+ {item?.evidences?.map((evidence, index) => (
+
+ ))}
+
+ ),
+ )}
+ >
+ ) : (
+
+
+
+
+
+
+
+
+
+ You have no cases! Create the first one
+
+
+
+ act('add_case')}
+ />
+
+
+
+
+ )}
+
+
+ );
+}
diff --git a/tgui/packages/tgui/styles/interfaces/DetectiveBoard.scss b/tgui/packages/tgui/styles/interfaces/DetectiveBoard.scss
new file mode 100644
index 0000000000000..8aa8aae48bb1d
--- /dev/null
+++ b/tgui/packages/tgui/styles/interfaces/DetectiveBoard.scss
@@ -0,0 +1,99 @@
+@use '../colors.scss';
+@use 'sass:color';
+@use 'sass:math';
+@use '../base.scss';
+
+.BoardTab {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ color: black;
+ min-height: 2.25em;
+ min-width: 4em;
+ border-radius: 5px 5px 0 0;
+}
+.BoardTab__Text {
+ margin-right: 5px;
+}
+.BoardTab__Contain {
+ flex-grow: 1;
+ margin: 0 0.5em;
+}
+.BoardTab__Perspective:not(.BoardTab__Contain) {
+ perspective-origin: 50%;
+ transform-origin: 50% 100%;
+ transform: perspective(100px) rotateX(25deg);
+}
+
+.BoardTab__Selected {
+ background-color: #edcf64;
+ border-bottom: 1px solid #edcf64;
+ transition: all 0.2s;
+}
+
+.BoardTabs {
+ display: flex;
+ align-items: stretch;
+ overflow: hidden;
+}
+.Evidence__Pin {
+ box-shadow: 0 0 10px rgba(0, 0, 0, 0.2);
+ background-color: #db2828;
+ border-radius: 20px;
+ width: 15px;
+ height: 15px;
+}
+
+.Evidence__Box {
+ box-shadow: 0 0 10px rgba(0, 0, 0, 0.4);
+ padding: 5px;
+ color: black;
+ min-width: 200px;
+ max-width: 300px;
+ background-color: white;
+ border: 2px solid grey;
+ -ms-user-select: none;
+ user-select: none;
+ text-wrap: wrap;
+ cursor: pointer;
+}
+
+.Evidence__Box__TextBox {
+ border-top: 1px solid #eaeaea;
+ text-wrap: wrap;
+ padding: 5px 0;
+ margin-top: 5px;
+ max-width: 240px;
+ text-align: center;
+
+ &.title {
+ border-top: none;
+ margin-top: 0;
+ }
+}
+
+.Board__Content {
+ position: relative;
+ background-color: #edcf64;
+ padding: 5px;
+ overflow: hidden;
+ height: 95%;
+}
+
+.Evidence__Icon {
+ margin-top: 5px;
+ border: 5px solid #e0e0e0;
+ width: 100%;
+}
+
+@each $color-name, $color-value in colors.$fg-map {
+ .BoardTab__#{$color-name} {
+ color: black;
+ background-color: $color-value;
+ transition: all 0.2s;
+ cursor: pointer;
+ }
+ .BoardTab__#{$color-name}:hover {
+ background-color: color.scale($color-value, $lightness: 25%);
+ }
+}
diff --git a/tgui/packages/tgui/styles/main.scss b/tgui/packages/tgui/styles/main.scss
index e5b12e83256bf..5a0e80715ee3b 100644
--- a/tgui/packages/tgui/styles/main.scss
+++ b/tgui/packages/tgui/styles/main.scss
@@ -78,6 +78,7 @@
@include meta.load-css('./interfaces/Trophycase.scss');
@include meta.load-css('./interfaces/Uplink.scss');
@include meta.load-css('./interfaces/UtilityModulesPane.scss');
+@include meta.load-css('./interfaces/DetectiveBoard.scss');
// Layouts
@include meta.load-css('./layouts/Layout.scss');