beadcyte 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +73 -0
- package/LICENSE +661 -0
- package/README.md +386 -0
- package/THIRD_PARTY_NOTICES.md +56 -0
- package/bin/beadcyte.mjs +60 -0
- package/package.json +77 -0
- package/src/changelog-cli.mjs +105 -0
- package/src/changelog.mjs +196 -0
- package/src/cli.mjs +577 -0
- package/src/estimator.mjs +314 -0
- package/src/format.mjs +22 -0
- package/src/history-walk.mjs +170 -0
- package/src/index.mjs +5 -0
- package/src/mutate.mjs +193 -0
- package/src/projects.mjs +120 -0
- package/src/provenance.mjs +75 -0
- package/src/review-hours.mjs +117 -0
- package/src/roster-path.mjs +24 -0
- package/src/scheduler.mjs +424 -0
- package/src/serve.mjs +411 -0
- package/src/server-state.mjs +105 -0
- package/src/ship.mjs +178 -0
- package/src/stage-waits.mjs +69 -0
- package/src/start.mjs +111 -0
- package/src/stop.mjs +66 -0
- package/src/velocity.mjs +209 -0
- package/src/web/App.vue +691 -0
- package/src/web/app.css +54 -0
- package/src/web/assets/favicon.svg +12 -0
- package/src/web/avatar.ts +53 -0
- package/src/web/bead-detail.ts +240 -0
- package/src/web/changelog-view.ts +41 -0
- package/src/web/components/BeadDrawer.vue +1883 -0
- package/src/web/components/BeadSubGraph.vue +326 -0
- package/src/web/components/BeadSubGraphOverlay.vue +192 -0
- package/src/web/components/BeadTooltip.vue +516 -0
- package/src/web/components/BeadcyteMark.vue +64 -0
- package/src/web/components/BeadsGantt.vue +2125 -0
- package/src/web/components/BeadsGrid.vue +468 -0
- package/src/web/components/BeadsIncytes.vue +567 -0
- package/src/web/components/BeadsMine.vue +325 -0
- package/src/web/components/BeadsTable.vue +335 -0
- package/src/web/components/ChangelogOverlay.vue +198 -0
- package/src/web/components/ContextMenu.vue +386 -0
- package/src/web/components/ControlsPanel.vue +476 -0
- package/src/web/components/CostTrend.vue +206 -0
- package/src/web/components/FilterPopover.vue +245 -0
- package/src/web/components/GroupProgress.vue +274 -0
- package/src/web/components/LoadMeter.vue +144 -0
- package/src/web/components/MineRow.vue +28 -0
- package/src/web/components/OptionsMenu.vue +825 -0
- package/src/web/components/PriorityChip.vue +105 -0
- package/src/web/components/ScoreStrip.vue +131 -0
- package/src/web/components/SearchPalette.vue +210 -0
- package/src/web/components/ShipTrend.vue +510 -0
- package/src/web/components/ShortcutsOverlay.vue +164 -0
- package/src/web/components/Term.vue +177 -0
- package/src/web/components/Toast.vue +50 -0
- package/src/web/components/TriageMeters.vue +426 -0
- package/src/web/components/TypeChip.vue +96 -0
- package/src/web/components/Walkthrough.vue +209 -0
- package/src/web/components/WhatIfPanel.vue +206 -0
- package/src/web/components/WipBullets.vue +191 -0
- package/src/web/components/filter-option.ts +9 -0
- package/src/web/composables/url-codec.ts +136 -0
- package/src/web/composables/useBeadTooltip.ts +148 -0
- package/src/web/composables/useKeyboard.ts +97 -0
- package/src/web/composables/useLiveRefresh.ts +69 -0
- package/src/web/composables/useTheme.ts +125 -0
- package/src/web/composables/useUrlState.ts +208 -0
- package/src/web/controls-scope.ts +83 -0
- package/src/web/cost.ts +251 -0
- package/src/web/dep-headings.ts +62 -0
- package/src/web/economics.ts +440 -0
- package/src/web/env.d.ts +85 -0
- package/src/web/frontier.ts +208 -0
- package/src/web/gantt-viewport.ts +99 -0
- package/src/web/highlights.ts +124 -0
- package/src/web/index.html +46 -0
- package/src/web/insights.ts +107 -0
- package/src/web/keybindings.ts +200 -0
- package/src/web/load-meter.ts +72 -0
- package/src/web/main.ts +20 -0
- package/src/web/markdown.ts +14 -0
- package/src/web/mine.ts +137 -0
- package/src/web/mutations.ts +21 -0
- package/src/web/person.ts +102 -0
- package/src/web/projects-text.ts +15 -0
- package/src/web/projects.ts +188 -0
- package/src/web/refresh.ts +47 -0
- package/src/web/search.ts +50 -0
- package/src/web/shortcuts.ts +113 -0
- package/src/web/status-filter.ts +48 -0
- package/src/web/store.ts +1378 -0
- package/src/web/style-audit.mjs +346 -0
- package/src/web/styles-alt.css +111 -0
- package/src/web/styles-ported.css +270 -0
- package/src/web/subgraph.ts +362 -0
- package/src/web/table.ts +201 -0
- package/src/web/theme.ts +88 -0
- package/src/web/tokens.css +168 -0
- package/src/web/triage.ts +914 -0
- package/src/web/view-model.ts +717 -0
- package/src/web/walkthrough.ts +133 -0
- package/src/web/watchlist.ts +47 -0
- package/src/web/whatif.ts +291 -0
- package/src/web/window.ts +73 -0
- package/src/web/wip.ts +83 -0
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Term.vue — an explainable term (bp-67g.47).
|
|
3
|
+
//
|
|
4
|
+
// Every derived number in the drawer can explain itself. This is the ONE
|
|
5
|
+
// mechanism for that, decided once so it does not vary field by field:
|
|
6
|
+
//
|
|
7
|
+
// - The term is drawn with a dotted underline and is focusable
|
|
8
|
+
// (tabindex 0, role button), so the explanation is reachable by keyboard
|
|
9
|
+
// and announced through aria-describedby while it is shown.
|
|
10
|
+
// - Hover shows the note; so does focus (Tab lands on it).
|
|
11
|
+
// - Click, tap, Enter or Space PIN it, so it survives the pointer leaving
|
|
12
|
+
// and works on touch, where there is no hover. A second click, Escape,
|
|
13
|
+
// a tap or click anywhere else, or any scroll dismisses it.
|
|
14
|
+
// - The note is teleported to <body> and fixed to the viewport, placed
|
|
15
|
+
// under the term (above it when the viewport runs out), so a scrolling
|
|
16
|
+
// drawer cannot clip it. Scroll dismisses rather than re-anchors, because
|
|
17
|
+
// a note pointing at where a term used to be is worse than none.
|
|
18
|
+
//
|
|
19
|
+
// A `title` attribute would have been cheaper and is unreachable by keyboard
|
|
20
|
+
// and on touch; that is why this exists. The sentence itself must come from
|
|
21
|
+
// the module that computes the figure (see triage.ts explainSignal,
|
|
22
|
+
// bead-detail.ts explainFinish, economics.ts PHASE_EXPLANATIONS), never be
|
|
23
|
+
// typed here, so a formula change cannot leave a stale description behind.
|
|
24
|
+
|
|
25
|
+
import { computed, nextTick, onBeforeUnmount, ref, watch } from "vue";
|
|
26
|
+
|
|
27
|
+
const props = defineProps<{ note: string }>();
|
|
28
|
+
|
|
29
|
+
const id = `term-note-${Math.random().toString(36).slice(2, 9)}`;
|
|
30
|
+
const termEl = ref<HTMLElement | null>(null);
|
|
31
|
+
const noteEl = ref<HTMLElement | null>(null);
|
|
32
|
+
const hover = ref(false);
|
|
33
|
+
const focused = ref(false);
|
|
34
|
+
const pinned = ref(false);
|
|
35
|
+
const visible = computed(() => hover.value || focused.value || pinned.value);
|
|
36
|
+
const pos = ref({ left: "0px", top: "0px" });
|
|
37
|
+
|
|
38
|
+
async function place() {
|
|
39
|
+
await nextTick();
|
|
40
|
+
const t = termEl.value?.getBoundingClientRect();
|
|
41
|
+
const n = noteEl.value;
|
|
42
|
+
if (!t || !n) return;
|
|
43
|
+
const pad = 12;
|
|
44
|
+
const gap = 6;
|
|
45
|
+
const w = n.offsetWidth;
|
|
46
|
+
const h = n.offsetHeight;
|
|
47
|
+
const left = Math.min(Math.max(pad, t.left), window.innerWidth - w - pad);
|
|
48
|
+
let top = t.bottom + gap;
|
|
49
|
+
if (top + h + pad > window.innerHeight) top = Math.max(pad, t.top - gap - h);
|
|
50
|
+
pos.value = { left: `${left}px`, top: `${top}px` };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function dismiss() {
|
|
54
|
+
hover.value = false;
|
|
55
|
+
focused.value = false;
|
|
56
|
+
pinned.value = false;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function onOutside(e: Event) {
|
|
60
|
+
const target = e.target as Node | null;
|
|
61
|
+
if (target && (termEl.value?.contains(target) || noteEl.value?.contains(target))) return;
|
|
62
|
+
dismiss();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function onClick() {
|
|
66
|
+
if (pinned.value) dismiss();
|
|
67
|
+
else pinned.value = true;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function onKeydown(e: KeyboardEvent) {
|
|
71
|
+
if (e.key === "Escape") {
|
|
72
|
+
if (!visible.value) return;
|
|
73
|
+
// Ours, not the drawer's: one Escape closes the note, the next closes
|
|
74
|
+
// the drawer.
|
|
75
|
+
e.stopPropagation();
|
|
76
|
+
dismiss();
|
|
77
|
+
termEl.value?.blur();
|
|
78
|
+
} else if (e.key === "Enter" || e.key === " ") {
|
|
79
|
+
e.preventDefault();
|
|
80
|
+
onClick();
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function listen(on: boolean) {
|
|
85
|
+
const m = on ? document.addEventListener : document.removeEventListener;
|
|
86
|
+
m.call(document, "scroll", dismiss, { capture: true, passive: true } as AddEventListenerOptions);
|
|
87
|
+
m.call(document, "pointerdown", onOutside, true);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
watch(visible, (v) => {
|
|
91
|
+
listen(v);
|
|
92
|
+
if (v) void place();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
onBeforeUnmount(() => listen(false));
|
|
96
|
+
</script>
|
|
97
|
+
|
|
98
|
+
<template>
|
|
99
|
+
<span class="term-wrap">
|
|
100
|
+
<span
|
|
101
|
+
ref="termEl"
|
|
102
|
+
class="term"
|
|
103
|
+
:class="{ 'is-open': visible }"
|
|
104
|
+
tabindex="0"
|
|
105
|
+
role="button"
|
|
106
|
+
:aria-expanded="visible"
|
|
107
|
+
:aria-describedby="visible ? id : undefined"
|
|
108
|
+
@mouseenter="hover = true"
|
|
109
|
+
@mouseleave="hover = false"
|
|
110
|
+
@focus="focused = true"
|
|
111
|
+
@blur="focused = false"
|
|
112
|
+
@click="onClick"
|
|
113
|
+
@keydown="onKeydown"
|
|
114
|
+
><slot /></span>
|
|
115
|
+
<Teleport to="body">
|
|
116
|
+
<span
|
|
117
|
+
v-if="visible"
|
|
118
|
+
ref="noteEl"
|
|
119
|
+
:id="id"
|
|
120
|
+
class="term-note"
|
|
121
|
+
role="tooltip"
|
|
122
|
+
:style="{ left: pos.left, top: pos.top }"
|
|
123
|
+
>{{ props.note }}</span>
|
|
124
|
+
</Teleport>
|
|
125
|
+
</span>
|
|
126
|
+
</template>
|
|
127
|
+
|
|
128
|
+
<style>
|
|
129
|
+
.term {
|
|
130
|
+
text-decoration: underline dotted;
|
|
131
|
+
text-decoration-color: var(--c-dim-bright);
|
|
132
|
+
text-decoration-thickness: 1px;
|
|
133
|
+
text-underline-offset: 2px;
|
|
134
|
+
cursor: help;
|
|
135
|
+
border-radius: 2px;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
.term:hover,
|
|
139
|
+
.term.is-open {
|
|
140
|
+
text-decoration-color: var(--c-accent);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
.term:focus-visible {
|
|
144
|
+
outline: 2px solid var(--c-accent);
|
|
145
|
+
outline-offset: 1px;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/* The note resets every inherited text style: a term inside an uppercase,
|
|
149
|
+
letter-spaced <dt> must not produce an uppercase, letter-spaced sentence. */
|
|
150
|
+
.term-note {
|
|
151
|
+
position: fixed;
|
|
152
|
+
z-index: 9998;
|
|
153
|
+
display: block;
|
|
154
|
+
max-width: 300px;
|
|
155
|
+
padding: 8px 10px;
|
|
156
|
+
background: var(--c-panel);
|
|
157
|
+
color: var(--c-text);
|
|
158
|
+
border: 1px solid var(--c-border-strong);
|
|
159
|
+
border-radius: 6px;
|
|
160
|
+
box-shadow: var(--shadow-pop);
|
|
161
|
+
font-size: 12px;
|
|
162
|
+
line-height: 1.45;
|
|
163
|
+
font-weight: 400;
|
|
164
|
+
font-style: normal;
|
|
165
|
+
text-transform: none;
|
|
166
|
+
letter-spacing: normal;
|
|
167
|
+
text-align: left;
|
|
168
|
+
white-space: normal;
|
|
169
|
+
font-family:
|
|
170
|
+
ui-sans-serif,
|
|
171
|
+
-apple-system,
|
|
172
|
+
Segoe UI,
|
|
173
|
+
Roboto,
|
|
174
|
+
sans-serif;
|
|
175
|
+
pointer-events: none;
|
|
176
|
+
}
|
|
177
|
+
</style>
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Toast.vue — one line at the bottom of the screen for the outcome of a
|
|
3
|
+
// write (bp-ocs): what happened, or what failed and why. Announced as a status,
|
|
4
|
+
// dismissed by its own timer or a click.
|
|
5
|
+
import { useBeadsStore } from "../store";
|
|
6
|
+
const store = useBeadsStore();
|
|
7
|
+
</script>
|
|
8
|
+
|
|
9
|
+
<template>
|
|
10
|
+
<Teleport to="body">
|
|
11
|
+
<div v-if="store.notice" class="toast" :class="`is-${store.notice.kind}`" role="status" @click="store.clearNotice()">
|
|
12
|
+
<span class="toast-text">{{ store.notice.text }}</span>
|
|
13
|
+
</div>
|
|
14
|
+
</Teleport>
|
|
15
|
+
</template>
|
|
16
|
+
|
|
17
|
+
<style>
|
|
18
|
+
.toast {
|
|
19
|
+
position: fixed;
|
|
20
|
+
left: 50%;
|
|
21
|
+
bottom: 24px;
|
|
22
|
+
transform: translateX(-50%);
|
|
23
|
+
z-index: 9995;
|
|
24
|
+
max-width: min(720px, calc(100vw - 32px));
|
|
25
|
+
padding: 8px 12px;
|
|
26
|
+
border-radius: 6px;
|
|
27
|
+
background: var(--c-panel);
|
|
28
|
+
color: var(--c-text);
|
|
29
|
+
border: 1px solid var(--c-border-strong);
|
|
30
|
+
box-shadow: var(--shadow-pop);
|
|
31
|
+
font-size: 12px;
|
|
32
|
+
display: flex;
|
|
33
|
+
gap: 10px;
|
|
34
|
+
align-items: baseline;
|
|
35
|
+
cursor: pointer;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.toast.is-error {
|
|
39
|
+
border-color: var(--c-danger);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
.toast .toast-cmd {
|
|
43
|
+
font-family: ui-monospace, monospace;
|
|
44
|
+
font-size: 11px;
|
|
45
|
+
color: var(--c-dim);
|
|
46
|
+
white-space: nowrap;
|
|
47
|
+
overflow: hidden;
|
|
48
|
+
text-overflow: ellipsis;
|
|
49
|
+
}
|
|
50
|
+
</style>
|
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// TriageMeters.vue — the eight triage signals as contribution meters.
|
|
3
|
+
//
|
|
4
|
+
// Replaces the four-decimal breakdown table (bp-67g.27). The table is still
|
|
5
|
+
// here, behind a disclosure, because no value should be reachable only by
|
|
6
|
+
// reading a bar.
|
|
7
|
+
//
|
|
8
|
+
// The encoding is the whole point, and it is what bp-67g.16 rejected a
|
|
9
|
+
// PolarChart radar in favour of:
|
|
10
|
+
//
|
|
11
|
+
// TRACK the signal's declared weight, scaled against the largest. An
|
|
12
|
+
// axis that can only ever reach 0.05 gets a quarter of the width
|
|
13
|
+
// of one that can reach 0.22.
|
|
14
|
+
// CARVE the part of the weight the signal can NEVER spend, where its
|
|
15
|
+
// norm is capped below 1 (bp-67g.23). risk declares 0.10 and can
|
|
16
|
+
// reach 0.002, so 98% of its track is cut away.
|
|
17
|
+
// FILL what this bead actually contributed.
|
|
18
|
+
// P90 where the 90th percentile of the open population sits, so a
|
|
19
|
+
// fill can be read against what "high" means on this repo. The
|
|
20
|
+
// absolute scales are meaningful but narrow in practice —
|
|
21
|
+
// nothing healthy is 30 days stale — so without this marker
|
|
22
|
+
// every bar looks empty and the panel says nothing.
|
|
23
|
+
//
|
|
24
|
+
// The carve-out started life as a notch at the achievable ceiling and was
|
|
25
|
+
// invisible: both capped signals have norms that sit AT their maximum, so
|
|
26
|
+
// the notch landed exactly on the fill edge every time. Removing the
|
|
27
|
+
// unreachable region instead makes it an area rather than a line, and one
|
|
28
|
+
// that cannot coincide with anything.
|
|
29
|
+
//
|
|
30
|
+
// A radar cannot express any of that: it has one magnitude domain, so it
|
|
31
|
+
// plots the norm and makes a full-radius staleness spike (weight 0.05) look
|
|
32
|
+
// identical to a full-radius pagerank spike (weight 0.22) while the two
|
|
33
|
+
// contribute 4.4x different amounts.
|
|
34
|
+
//
|
|
35
|
+
// Rows are in fixed weight order, never sorted by this bead's own values —
|
|
36
|
+
// fixed order is what lets two beads be compared by glancing down the same
|
|
37
|
+
// rows, and weight order puts the signals that could move the score first.
|
|
38
|
+
|
|
39
|
+
import { computed } from "vue";
|
|
40
|
+
import {
|
|
41
|
+
TRIAGE_WEIGHTS,
|
|
42
|
+
SIGNAL_FIDELITY,
|
|
43
|
+
achievableContribution,
|
|
44
|
+
type SignalPopulation,
|
|
45
|
+
type TriageBreakdown,
|
|
46
|
+
type TriageSignal,
|
|
47
|
+
explainSignal,
|
|
48
|
+
} from "../triage";
|
|
49
|
+
import Term from "./Term.vue";
|
|
50
|
+
|
|
51
|
+
const props = defineProps<{
|
|
52
|
+
breakdown: TriageBreakdown;
|
|
53
|
+
/** Spread across the open population, for the flat markers. */
|
|
54
|
+
population: SignalPopulation[];
|
|
55
|
+
}>();
|
|
56
|
+
|
|
57
|
+
const LABELS: Record<TriageSignal, string> = {
|
|
58
|
+
pagerank: "PageRank",
|
|
59
|
+
betweenness: "Betweenness",
|
|
60
|
+
blocker_ratio: "Blocker ratio",
|
|
61
|
+
priority_boost: "Priority",
|
|
62
|
+
time_to_impact: "Time to impact",
|
|
63
|
+
urgency: "Urgency",
|
|
64
|
+
staleness: "Staleness",
|
|
65
|
+
risk: "Risk",
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const maxWeight = computed(() =>
|
|
69
|
+
Math.max(...Object.values(TRIAGE_WEIGHTS)),
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
interface MeterRow {
|
|
73
|
+
signal: TriageSignal;
|
|
74
|
+
label: string;
|
|
75
|
+
norm: number;
|
|
76
|
+
weight: number;
|
|
77
|
+
contribution: number;
|
|
78
|
+
/** Contribution as a share of this bead's whole score. */
|
|
79
|
+
share: number;
|
|
80
|
+
/** Fill width, as a percentage OF THE TRACK. */
|
|
81
|
+
fillPct: number;
|
|
82
|
+
/** Notch position, as a percentage of the track. 100 = no cap. */
|
|
83
|
+
ceilingPct: number;
|
|
84
|
+
capped: boolean;
|
|
85
|
+
fidelity: string;
|
|
86
|
+
note?: string;
|
|
87
|
+
degenerate: boolean;
|
|
88
|
+
/** Population p90 as a percentage of the track. Null when flat. */
|
|
89
|
+
p90Pct: number | null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const rows = computed<MeterRow[]>(() => {
|
|
93
|
+
const byName = new Map(props.population.map((p) => [p.signal, p] as const));
|
|
94
|
+
const score = Object.keys(TRIAGE_WEIGHTS).reduce(
|
|
95
|
+
(a, k) => a + props.breakdown[k as TriageSignal],
|
|
96
|
+
0,
|
|
97
|
+
);
|
|
98
|
+
return (Object.keys(TRIAGE_WEIGHTS) as TriageSignal[])
|
|
99
|
+
.map((signal) => {
|
|
100
|
+
const weight = TRIAGE_WEIGHTS[signal];
|
|
101
|
+
const ceiling = achievableContribution(signal);
|
|
102
|
+
const contribution = props.breakdown[signal];
|
|
103
|
+
const fid = SIGNAL_FIDELITY[signal];
|
|
104
|
+
return {
|
|
105
|
+
signal,
|
|
106
|
+
label: LABELS[signal],
|
|
107
|
+
norm: props.breakdown[`${signal}_norm` as keyof TriageBreakdown],
|
|
108
|
+
weight,
|
|
109
|
+
contribution,
|
|
110
|
+
share: score > 0 ? contribution / score : 0,
|
|
111
|
+
// Against the WEIGHT, not the ceiling: a fill that cannot leave the
|
|
112
|
+
// left edge of a full-width track is the shape of a signal whose
|
|
113
|
+
// declared weight it can never spend.
|
|
114
|
+
fillPct: weight > 0 ? Math.min(100, (contribution / weight) * 100) : 0,
|
|
115
|
+
ceilingPct: weight > 0 ? Math.min(100, (ceiling / weight) * 100) : 100,
|
|
116
|
+
capped: fid.max < 1,
|
|
117
|
+
fidelity: fid.fidelity,
|
|
118
|
+
note: fid.note,
|
|
119
|
+
degenerate: byName.get(signal)?.degenerate ?? false,
|
|
120
|
+
// Skip the marker on a flat signal: with no spread, p90 sits on top
|
|
121
|
+
// of everything else and the marker would imply a comparison the
|
|
122
|
+
// data cannot support.
|
|
123
|
+
p90Pct: (() => {
|
|
124
|
+
const pop = byName.get(signal);
|
|
125
|
+
return pop && !pop.degenerate ? Math.min(100, pop.p90 * 100) : null;
|
|
126
|
+
})(),
|
|
127
|
+
};
|
|
128
|
+
})
|
|
129
|
+
.sort((a, b) => b.weight - a.weight);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
const flatCount = computed(() => rows.value.filter((r) => r.degenerate).length);
|
|
133
|
+
const cappedCount = computed(() => rows.value.filter((r) => r.capped).length);
|
|
134
|
+
const populationSize = computed(() => props.population[0]?.nonzero ?? 0);
|
|
135
|
+
|
|
136
|
+
/** The one signal that contributed most — the answer to "why this rank". */
|
|
137
|
+
const topContributor = computed(() =>
|
|
138
|
+
rows.value.reduce((a, r) => (r.contribution > a.contribution ? r : a), rows.value[0]),
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
const fmt4 = (v: number) => v.toFixed(4);
|
|
142
|
+
|
|
143
|
+
const byNameP90 = (signal: TriageSignal) =>
|
|
144
|
+
props.population.find((p) => p.signal === signal)?.p90;
|
|
145
|
+
const fmtWeight = (v: number) => v.toFixed(2).slice(1);
|
|
146
|
+
</script>
|
|
147
|
+
|
|
148
|
+
<template>
|
|
149
|
+
<div class="meters-wrap">
|
|
150
|
+
<p class="meters-lede">
|
|
151
|
+
Track is the signal's weight; the fill is what this bead contributed.
|
|
152
|
+
<template v-if="topContributor">
|
|
153
|
+
Most of this score is
|
|
154
|
+
<strong>{{ topContributor.label.toLowerCase() }}</strong>
|
|
155
|
+
({{ Math.round(topContributor.share * 100) }}%).
|
|
156
|
+
</template>
|
|
157
|
+
<template v-if="cappedCount">
|
|
158
|
+
A cut-off track is weight the signal cannot reach.
|
|
159
|
+
</template>
|
|
160
|
+
The tick is this repo's p90 — what a high value looks like here.
|
|
161
|
+
</p>
|
|
162
|
+
|
|
163
|
+
<ul class="meters">
|
|
164
|
+
<li
|
|
165
|
+
v-for="r in rows"
|
|
166
|
+
:key="r.signal"
|
|
167
|
+
:class="{ 'is-flat': r.degenerate, 'is-caveated': r.fidelity !== 'faithful' }"
|
|
168
|
+
>
|
|
169
|
+
<!-- Each name explains what it measures, from triage.ts itself
|
|
170
|
+
(bp-67g.47); the flat marker uses the same mechanism. -->
|
|
171
|
+
<span class="m-label">
|
|
172
|
+
<Term :note="explainSignal(r.signal)">{{ r.label }}</Term>
|
|
173
|
+
<Term
|
|
174
|
+
v-if="r.degenerate"
|
|
175
|
+
class="flat"
|
|
176
|
+
note="Three or fewer distinct values across the open beads in this repo — this axis is not separating anything here"
|
|
177
|
+
>flat</Term>
|
|
178
|
+
</span>
|
|
179
|
+
<span class="m-weight" :title="`weight ${fmt4(r.weight)}`">
|
|
180
|
+
{{ fmtWeight(r.weight) }}
|
|
181
|
+
</span>
|
|
182
|
+
<span
|
|
183
|
+
class="m-track"
|
|
184
|
+
:style="{ width: `${(r.weight / maxWeight) * 100}%` }"
|
|
185
|
+
>
|
|
186
|
+
<i class="m-fill" :style="{ width: `${r.fillPct}%` }" />
|
|
187
|
+
<!-- The weight this signal cannot spend, cut out of the track. -->
|
|
188
|
+
<!-- What a high value looks like on this repo. -->
|
|
189
|
+
<i
|
|
190
|
+
v-if="r.p90Pct !== null"
|
|
191
|
+
class="m-p90"
|
|
192
|
+
:style="{ left: `${r.p90Pct}%` }"
|
|
193
|
+
:title="`p90 of the open population is ${fmt4((byNameP90(r.signal) ?? 0))} — this bar is read against that`"
|
|
194
|
+
/>
|
|
195
|
+
<i
|
|
196
|
+
v-if="r.capped"
|
|
197
|
+
class="m-unreachable"
|
|
198
|
+
:style="{ left: `${r.ceilingPct}%` }"
|
|
199
|
+
:title="`can only reach ${fmt4(achievableContribution(r.signal))} of its ${fmt4(r.weight)} weight — the rest is unspendable`"
|
|
200
|
+
/>
|
|
201
|
+
</span>
|
|
202
|
+
<span class="m-value">{{ fmt4(r.contribution) }}</span>
|
|
203
|
+
</li>
|
|
204
|
+
</ul>
|
|
205
|
+
|
|
206
|
+
<!-- No longer claims urgency is staleness x priority: bp-67g.37
|
|
207
|
+
replaced that formula with urgency labels plus an age decay, so it
|
|
208
|
+
is an independent axis now. -->
|
|
209
|
+
<p v-if="flatCount" class="meters-note">
|
|
210
|
+
{{ flatCount }} of {{ rows.length }} signals are flat across this
|
|
211
|
+
repo's open beads, so they separate nothing here regardless of their
|
|
212
|
+
weight — usually because the dependency graph is too shallow to give
|
|
213
|
+
them anything to measure.
|
|
214
|
+
</p>
|
|
215
|
+
|
|
216
|
+
<details class="meters-table">
|
|
217
|
+
<summary>Table — norm, weight, ceiling and contribution</summary>
|
|
218
|
+
<table>
|
|
219
|
+
<thead>
|
|
220
|
+
<tr>
|
|
221
|
+
<th>Signal</th>
|
|
222
|
+
<th class="num">Norm</th>
|
|
223
|
+
<th class="num">Weight</th>
|
|
224
|
+
<th class="num">Max</th>
|
|
225
|
+
<th class="num">Contrib</th>
|
|
226
|
+
<th>vs bv</th>
|
|
227
|
+
</tr>
|
|
228
|
+
</thead>
|
|
229
|
+
<tbody>
|
|
230
|
+
<tr v-for="r in rows" :key="r.signal" :class="{ 'is-flat': r.degenerate }">
|
|
231
|
+
<td>{{ r.label }}</td>
|
|
232
|
+
<td class="num">{{ fmt4(r.norm) }}</td>
|
|
233
|
+
<td class="num">{{ fmt4(r.weight) }}</td>
|
|
234
|
+
<td class="num" :class="{ 'is-capped': r.capped }">
|
|
235
|
+
{{ fmt4(achievableContribution(r.signal)) }}
|
|
236
|
+
</td>
|
|
237
|
+
<td class="num">{{ fmt4(r.contribution) }}</td>
|
|
238
|
+
<td class="fid" :title="r.note">{{ r.fidelity }}</td>
|
|
239
|
+
</tr>
|
|
240
|
+
</tbody>
|
|
241
|
+
</table>
|
|
242
|
+
</details>
|
|
243
|
+
</div>
|
|
244
|
+
</template>
|
|
245
|
+
|
|
246
|
+
<style scoped>
|
|
247
|
+
.meters-lede,
|
|
248
|
+
.meters-note {
|
|
249
|
+
font-size: 10px;
|
|
250
|
+
line-height: 1.5;
|
|
251
|
+
color: var(--c-dim);
|
|
252
|
+
margin: 0 0 10px;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
.meters-lede strong {
|
|
256
|
+
color: var(--c-text);
|
|
257
|
+
font-weight: 600;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
.meters-note {
|
|
261
|
+
margin: 10px 0 0;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
.meters-note code {
|
|
265
|
+
font-family: var(--font-mono);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
.meters {
|
|
269
|
+
list-style: none;
|
|
270
|
+
margin: 0;
|
|
271
|
+
padding: 0;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
.meters li {
|
|
275
|
+
display: grid;
|
|
276
|
+
grid-template-columns: 96px 24px 1fr 52px;
|
|
277
|
+
align-items: center;
|
|
278
|
+
gap: 8px;
|
|
279
|
+
padding: 2px 0;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
.m-label {
|
|
283
|
+
font-size: 11px;
|
|
284
|
+
color: var(--c-text);
|
|
285
|
+
white-space: nowrap;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
.meters li.is-flat .m-label {
|
|
289
|
+
color: var(--c-dim);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
.m-label .flat {
|
|
293
|
+
font-size: 8px;
|
|
294
|
+
text-transform: uppercase;
|
|
295
|
+
letter-spacing: 0.06em;
|
|
296
|
+
color: var(--c-warning-strong);
|
|
297
|
+
border: 1px solid currentColor;
|
|
298
|
+
border-radius: 2px;
|
|
299
|
+
padding: 0 2px;
|
|
300
|
+
margin-left: 4px;
|
|
301
|
+
text-decoration: none;
|
|
302
|
+
cursor: help;
|
|
303
|
+
vertical-align: 1px;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
.m-weight {
|
|
307
|
+
font-size: 10px;
|
|
308
|
+
color: var(--c-dim);
|
|
309
|
+
font-variant-numeric: tabular-nums;
|
|
310
|
+
text-align: right;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/* The track IS the weight. Width is set inline per row, so a 0.05 signal is
|
|
314
|
+
visibly a quarter the reach of a 0.22 one before any value is read. */
|
|
315
|
+
.m-track {
|
|
316
|
+
display: block;
|
|
317
|
+
height: 12px;
|
|
318
|
+
background: var(--c-dim-wash);
|
|
319
|
+
border-radius: 2px;
|
|
320
|
+
position: relative;
|
|
321
|
+
overflow: hidden;
|
|
322
|
+
min-width: 3px;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
.m-fill {
|
|
326
|
+
position: absolute;
|
|
327
|
+
left: 0;
|
|
328
|
+
top: 0;
|
|
329
|
+
height: 12px;
|
|
330
|
+
background: var(--c-accent);
|
|
331
|
+
border-radius: 2px 0 0 2px;
|
|
332
|
+
display: block;
|
|
333
|
+
/* A hairline of surface where a full fill meets the track end, so 100%
|
|
334
|
+
still reads as a fill rather than as a recoloured track. */
|
|
335
|
+
min-width: 1px;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/* The 90th percentile of the open population. Sits ON the fill rather than
|
|
339
|
+
beside it, because the comparison being made is "how far along this track
|
|
340
|
+
compared with a high bead", and two separate bars would be a chart of two
|
|
341
|
+
different things. */
|
|
342
|
+
.m-p90 {
|
|
343
|
+
position: absolute;
|
|
344
|
+
top: -2px;
|
|
345
|
+
width: 1px;
|
|
346
|
+
height: 16px;
|
|
347
|
+
background: var(--c-dim-bright);
|
|
348
|
+
transform: translateX(-0.5px);
|
|
349
|
+
display: block;
|
|
350
|
+
z-index: 1;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
.meters li.is-flat .m-p90 {
|
|
354
|
+
display: none;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/* Weight the signal cannot spend, carved back out of the track: the panel
|
|
358
|
+
shows through, with a hairline where capacity stops. Not a stroke around
|
|
359
|
+
a mark — the track's own absence is the encoding. */
|
|
360
|
+
.m-unreachable {
|
|
361
|
+
position: absolute;
|
|
362
|
+
top: 0;
|
|
363
|
+
right: 0;
|
|
364
|
+
height: 12px;
|
|
365
|
+
background: var(--c-panel);
|
|
366
|
+
border-left: 1px solid var(--c-warning-strong);
|
|
367
|
+
display: block;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
.m-value {
|
|
371
|
+
font-size: 10px;
|
|
372
|
+
color: var(--c-dim);
|
|
373
|
+
font-variant-numeric: tabular-nums;
|
|
374
|
+
text-align: right;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/* ── table view ───────────────────────────────────────────────────────── */
|
|
378
|
+
|
|
379
|
+
.meters-table {
|
|
380
|
+
margin-top: 12px;
|
|
381
|
+
font-size: 10px;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
.meters-table summary {
|
|
385
|
+
cursor: pointer;
|
|
386
|
+
color: var(--c-dim);
|
|
387
|
+
padding: 2px 0;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
.meters-table table {
|
|
391
|
+
border-collapse: collapse;
|
|
392
|
+
margin-top: 6px;
|
|
393
|
+
width: 100%;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
.meters-table th,
|
|
397
|
+
.meters-table td {
|
|
398
|
+
text-align: left;
|
|
399
|
+
padding: 2px 6px 2px 0;
|
|
400
|
+
border-bottom: 1px solid var(--c-border);
|
|
401
|
+
white-space: nowrap;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
.meters-table thead th {
|
|
405
|
+
color: var(--c-dim);
|
|
406
|
+
font-weight: 600;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
.meters-table .num {
|
|
410
|
+
text-align: right;
|
|
411
|
+
font-variant-numeric: tabular-nums;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
.meters-table td.is-capped {
|
|
415
|
+
color: var(--c-warning-strong);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
.meters-table td.fid {
|
|
419
|
+
color: var(--c-dim);
|
|
420
|
+
cursor: help;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
.meters-table tr.is-flat td {
|
|
424
|
+
color: var(--c-dim);
|
|
425
|
+
}
|
|
426
|
+
</style>
|