Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
446 changes: 446 additions & 0 deletions src/event-labeler/EventLabeler.tsx

Large diffs are not rendered by default.

407 changes: 407 additions & 0 deletions src/event-labeler/EventPane.tsx

Large diffs are not rendered by default.

63 changes: 63 additions & 0 deletions src/event-labeler/Gate.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { useState } from "react";

export interface GateProps {
initialWho: string;
initialApiBase: string;
resumeCount: number;
onStart: (who: string, apiBase: string) => void;
onGuide: () => void;
}

export default function Gate({ initialWho, initialApiBase, resumeCount, onStart, onGuide }: GateProps) {
const [who, setWho] = useState(initialWho);
const [apiBase, setApiBase] = useState(initialApiBase);

const start = () => {
const trimmed = who.trim();
if (!trimmed) return;
onStart(trimmed, apiBase.trim() || initialApiBase);
};

return (
<div id="gate">
<div className="gate-card">
<span className="eyebrow">Event adjudication</span>
<h1>Who is adjudicating?</h1>
<p>
Your name goes on every event you save, so disagreements stay traceable. Events are kept in
this browser only — nothing is written back to OONI. Import a draft to work from, and
export when you finish a session.
</p>
<label className="field">
<span>Adjudicator</span>
<input
type="text"
placeholder="e.g. john"
autoComplete="off"
value={who}
onChange={(e) => setWho(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") start();
}}
/>
</label>
<label className="field">
<span>API base</span>
<input type="text" value={apiBase} onChange={(e) => setApiBase(e.target.value)} />
</label>
<button className="btn btn-primary" onClick={start}>
Start adjudicating
</button>
<button className="btn" style={{ marginLeft: 8 }} onClick={onGuide}>
Read the guide
</button>
{resumeCount > 0 && (
<p className="hint" style={{ marginTop: 14, color: "var(--dim)" }}>
{resumeCount} event{resumeCount === 1 ? "" : "s"} already in this browser. Starting will
resume that session.
</p>
)}
</div>
</div>
);
}
235 changes: 235 additions & 0 deletions src/event-labeler/GradingPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
import { useEffect, useRef } from "react";
import MechanismPicker from "./MechanismPicker";
import { layersOf, ongoing, sizeBand } from "./derive";
import type { EventClass, Confidence, EventDraft, EventLabel, Scoreable } from "./types";

export type FlowStep = "class" | "mechanism" | "confidence" | "rationale";
const FLOW: FlowStep[] = ["class", "mechanism", "confidence", "rationale"];
const FLOW_NAMES: Record<FlowStep, string> = {
class: "Class",
mechanism: "Mechanisms",
confidence: "Confidence",
rationale: "Rationale",
};

const CLASSES: { value: EventClass; key: string; cls: string; text: string }[] = [
{ value: "true_event", key: "T", cls: "c-true", text: "True event" },
{ value: "false_positive_event", key: "F", cls: "c-false", text: "False alarm" },
{ value: "disputed", key: "D", cls: "c-disputed", text: "Disputed" },
];

const CONFS: { key: Confidence; label: string }[] = [
{ key: "certain", label: "Certain" },
{ key: "probable", label: "Probable" },
{ key: "uncertain", label: "Uncertain" },
];

const SCOREABLE: { key: Scoreable; label: string }[] = [
{ key: "yes", label: "yes" },
{ key: "no_coverage", label: "no_coverage" },
{ key: "unknown", label: "unknown" },
];

export interface GradingPanelProps {
title: string;
hasEvent: boolean;
draft: EventDraft;
resolved: EventLabel;
set: (patch: Partial<EventDraft>) => void;

flowStep: FlowStep;
onGotoStep: (s: FlowStep) => void;
mechRequiredError: boolean;

coverageLines: string[] | null;
coverageLoading: boolean;
onCheckCoverage: () => void;

onSave: () => void;
saveMsg: { kind: "ok" | "err" | "warn"; text: string } | null;
onNext: () => void;
onGuideLink: (anchor: string) => void;
}

export default function GradingPanel(props: GradingPanelProps) {
const { draft, set, resolved, flowStep, onGotoStep } = props;
const mechInputRef = useRef<HTMLInputElement>(null);
const whyRef = useRef<HTMLTextAreaElement>(null);

useEffect(() => {
if (flowStep === "mechanism") mechInputRef.current?.focus();
else if (flowStep === "rationale") whyRef.current?.focus();
else if (document.activeElement instanceof HTMLElement) document.activeElement.blur();
}, [flowStep]);

const layers = layersOf(resolved);

return (
<div className="judgment">
<span className="eyebrow">Your adjudication</span>
<h3>{props.title}</h3>
<p className="hint">
What did <em>this event</em> consist of? The measurements inside its window are judged
separately, on their own evidence.
</p>

<ol className="flow" title="The keyboard walks you through these in order">
{FLOW.map((step) => {
const i = FLOW.indexOf(step);
const at = FLOW.indexOf(flowStep);
const cls = [step === flowStep ? "on" : "", i < at ? "done" : ""].filter(Boolean).join(" ");
return (
<li key={step} className={cls} onClick={() => onGotoStep(step)}>
{FLOW_NAMES[step]}
</li>
);
})}
</ol>

<div className="choices">
{CLASSES.map((c) => (
<button
key={c.value}
className={"choice " + c.cls}
aria-pressed={draft.event_class === c.value}
onClick={() => set({ event_class: c.value })}
>
<kbd>{c.key}</kbd> {c.text}
</button>
))}
</div>
{draft.event_class === "false_positive_event" && (
<div className="stat" style={{ margin: "-8px 0 12px" }}>
An adjudicated false alarm is first-class: gold negatives for the measurement corpus, and a
must-not-fire regression test. The harness inverts the pass condition for this row.
</div>
)}

<label className="field">
<span className="eyebrow">
Mechanisms
<a
href="#g-mechanism"
className="guide-link"
style={{ float: "right", fontSize: 11, color: "var(--dim)", textTransform: "none", letterSpacing: 0 }}
onClick={(e) => {
e.preventDefault();
props.onGuideLink("#g-mechanism");
}}
>
taxonomy
</a>
</span>
<MechanismPicker
mechs={draft.mechanisms}
onChange={(mechanisms) => set({ mechanisms })}
requiredError={props.mechRequiredError}
inputRef={mechInputRef}
onDone={() => onGotoStep("confidence")}
onEscape={() => onGotoStep("class")}
/>
</label>

<span className="eyebrow">Confidence</span>
<div className="seg-group">
{CONFS.map((c) => (
<button
key={c.key}
className="btn"
aria-pressed={draft.confidence === c.key}
onClick={() => set({ confidence: c.key })}
>
{c.label}
</button>
))}
</div>

<span className="eyebrow">
Scoreable
<a
href="#g-scoreable"
className="guide-link"
style={{ float: "right", fontSize: 11, color: "var(--dim)", textTransform: "none", letterSpacing: 0 }}
onClick={(e) => {
e.preventDefault();
props.onGuideLink("#g-scoreable");
}}
>
why
</a>
</span>
<div className="seg-group">
{SCOREABLE.map((s) => (
<button
key={s.key}
className="btn"
aria-pressed={draft.scoreable === s.key}
onClick={() => set({ scoreable: s.key })}
>
{s.label}
</button>
))}
</div>
<button
className="btn"
style={{ width: "100%", marginBottom: 10 }}
disabled={props.coverageLoading}
onClick={props.onCheckCoverage}
>
{props.coverageLoading ? "Querying…" : "Check coverage"} <kbd style={{ opacity: 0.7 }}>C</kbd>
</button>
{props.coverageLines && <div className="cov">{props.coverageLines.join("\n")}</div>}

<label className="field">
<span className="eyebrow">Rationale</span>
<textarea
rows={4}
ref={whyRef}
placeholder="Name the evidence. 'Operator confirmed the order; OONI DNS answers in AS3352 turn to a single bogon within the bracket.'"
value={draft.rationale}
onChange={(e) => set({ rationale: e.target.value })}
onKeyDown={(e) => {
if (e.key === "Escape") {
(e.target as HTMLTextAreaElement).blur();
onGotoStep("confidence");
e.preventDefault();
}
}}
/>
</label>

<button
className="btn btn-primary"
style={{ width: "100%" }}
disabled={!props.hasEvent}
onClick={props.onSave}
>
Save event <kbd style={{ opacity: 0.7 }}>⌘⏎</kbd>
</button>
{props.saveMsg && (
<div className={"save-msg " + props.saveMsg.kind} role="status">
{props.saveMsg.text}
</div>
)}
<button className="btn" style={{ width: "100%", marginTop: 6 }} onClick={props.onNext}>
Next incomplete <kbd style={{ opacity: 0.7 }}>N</kbd>
</button>

<div className="derived-box">
<span className="eyebrow">Derived, never stored</span>
<dl>
<dt>ongoing</dt>
<dd>{String(ongoing(resolved))}</dd>
<dt>layers</dt>
<dd>{layers.length ? layers.join(", ") : "—"}</dd>
<dt>size_band</dt>
<dd>{sizeBand(resolved)}</dd>
</dl>
<p className="hint">
These are recomputed from the fields above every time they are read, so the corpus cannot
carry a size_band that contradicts its own scope.
</p>
</div>
</div>
);
}
Loading