Skip to content

Commit 7d7b463

Browse files
authored
Merge pull request #149 from LennartvdM/codex/add-horizontal-stacked-activity-time-bar
Add activity balance bar to radial visuals
2 parents 1b37adf + 8ab5a49 commit 7d7b463

5 files changed

Lines changed: 361 additions & 0 deletions

File tree

web/style.css

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1171,6 +1171,116 @@ body {
11711171
align-items: flex-start;
11721172
}
11731173

1174+
.radial-urchin__share {
1175+
position: relative;
1176+
display: flex;
1177+
flex-direction: column;
1178+
gap: 10px;
1179+
padding: 16px 18px;
1180+
background: rgba(15, 23, 42, 0.65);
1181+
border: 1px solid rgba(148, 163, 184, 0.2);
1182+
border-radius: 18px;
1183+
box-shadow: 0 18px 32px rgba(15, 23, 42, 0.32);
1184+
}
1185+
1186+
.activity-share__header {
1187+
display: flex;
1188+
align-items: center;
1189+
gap: 8px;
1190+
font-size: 12px;
1191+
font-weight: 600;
1192+
color: #cbd5f5;
1193+
text-transform: uppercase;
1194+
letter-spacing: 0.08em;
1195+
}
1196+
1197+
.activity-share__title {
1198+
color: #e2e8f0;
1199+
}
1200+
1201+
.activity-share__total {
1202+
font-size: 11px;
1203+
font-weight: 500;
1204+
color: rgba(148, 163, 184, 0.85);
1205+
}
1206+
1207+
.activity-share__track {
1208+
display: flex;
1209+
border-radius: 14px;
1210+
overflow: hidden;
1211+
min-height: 32px;
1212+
border: 1px solid rgba(148, 163, 184, 0.28);
1213+
background: rgba(148, 163, 184, 0.12);
1214+
box-shadow: inset 0 0 0 1px rgba(15, 23, 42, 0.25);
1215+
}
1216+
1217+
.activity-share__segment {
1218+
flex: 1 1 0%;
1219+
display: flex;
1220+
align-items: center;
1221+
justify-content: center;
1222+
position: relative;
1223+
padding: 4px 8px;
1224+
min-width: 6px;
1225+
background: var(--segment-color, #6366f1);
1226+
color: var(--segment-text-color, #0f172a);
1227+
transition: transform 0.18s ease;
1228+
cursor: pointer;
1229+
}
1230+
1231+
.activity-share__segment:hover {
1232+
transform: translateY(-1px);
1233+
}
1234+
1235+
.activity-share__segment-label {
1236+
font-size: 12px;
1237+
font-weight: 600;
1238+
text-shadow: 0 1px 2px rgba(15, 23, 42, 0.35);
1239+
white-space: nowrap;
1240+
}
1241+
1242+
.activity-share__empty {
1243+
margin: 0;
1244+
font-size: 12px;
1245+
color: rgba(148, 163, 184, 0.85);
1246+
}
1247+
1248+
.activity-share__tooltip {
1249+
position: absolute;
1250+
transform: translate(-50%, -100%) translateY(-12px);
1251+
background: rgba(15, 23, 42, 0.92);
1252+
border-radius: 12px;
1253+
padding: 10px 12px;
1254+
border: 1px solid rgba(148, 163, 184, 0.4);
1255+
box-shadow: 0 16px 28px rgba(15, 23, 42, 0.45);
1256+
display: flex;
1257+
flex-direction: column;
1258+
gap: 4px;
1259+
font-size: 12px;
1260+
pointer-events: none;
1261+
color: #f8fafc;
1262+
z-index: 2;
1263+
}
1264+
1265+
.activity-share__tooltip strong {
1266+
font-size: 13px;
1267+
font-weight: 700;
1268+
color: #e0e7ff;
1269+
}
1270+
1271+
.activity-share__tooltip span {
1272+
display: block;
1273+
color: rgba(226, 232, 240, 0.92);
1274+
}
1275+
1276+
.activity-share__track[hidden] {
1277+
display: none;
1278+
}
1279+
1280+
.activity-share__tooltip[hidden] {
1281+
display: none;
1282+
}
1283+
11741284
.radial-urchin__legend-empty {
11751285
margin: 0;
11761286
color: #94a3b8;

web/ui/visuals/ActivityShareBar.js

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
import { formatDuration } from './useUrchinLayout.js';
2+
3+
function toHexChannel(value) {
4+
const channel = Number.parseInt(value, 16);
5+
if (Number.isNaN(channel)) {
6+
return 0;
7+
}
8+
return channel;
9+
}
10+
11+
function computeTextColor(background) {
12+
if (typeof background !== 'string') {
13+
return '#0f172a';
14+
}
15+
let hex = background.trim();
16+
if (hex.startsWith('#')) {
17+
hex = hex.slice(1);
18+
}
19+
if (hex.length === 3) {
20+
hex = hex
21+
.split('')
22+
.map((char) => char + char)
23+
.join('');
24+
}
25+
if (hex.length !== 6) {
26+
return '#0f172a';
27+
}
28+
const r = toHexChannel(hex.slice(0, 2));
29+
const g = toHexChannel(hex.slice(2, 4));
30+
const b = toHexChannel(hex.slice(4, 6));
31+
const luminance = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
32+
return luminance > 0.6 ? '#0f172a' : '#f8fafc';
33+
}
34+
35+
export function prepareActivityShareSegments(activities) {
36+
const filtered = Array.isArray(activities)
37+
? activities.filter((activity) => Number.isFinite(activity?.minutes) && activity.minutes > 0)
38+
: [];
39+
const totalMinutes = filtered.reduce((sum, activity) => sum + activity.minutes, 0);
40+
if (totalMinutes <= 0) {
41+
return { totalMinutes: 0, segments: [] };
42+
}
43+
const segments = filtered.map((activity) => ({
44+
id: activity.id ?? activity.label,
45+
label: activity.label,
46+
minutes: activity.minutes,
47+
color: activity.color,
48+
percentage: activity.minutes / totalMinutes,
49+
}));
50+
return { totalMinutes, segments };
51+
}
52+
53+
export class ActivityShareBar {
54+
constructor(container) {
55+
this.root = container;
56+
this.root.classList.add('radial-urchin__share');
57+
this.root.setAttribute('role', 'group');
58+
this.root.setAttribute('aria-label', 'Activity balance overview');
59+
60+
this.header = document.createElement('div');
61+
this.header.className = 'activity-share__header';
62+
this.title = document.createElement('span');
63+
this.title.className = 'activity-share__title';
64+
this.title.textContent = 'Activity balance';
65+
this.total = document.createElement('span');
66+
this.total.className = 'activity-share__total';
67+
this.total.textContent = '—';
68+
this.header.append(this.title, this.total);
69+
70+
this.track = document.createElement('div');
71+
this.track.className = 'activity-share__track';
72+
this.track.setAttribute('role', 'list');
73+
this.track.hidden = true;
74+
this.track.addEventListener('mouseleave', () => {
75+
this.hideTooltip();
76+
});
77+
78+
this.empty = document.createElement('p');
79+
this.empty.className = 'activity-share__empty';
80+
this.empty.textContent = 'Select activities to see their balance.';
81+
this.empty.hidden = false;
82+
83+
this.tooltip = document.createElement('div');
84+
this.tooltip.className = 'activity-share__tooltip';
85+
this.tooltip.hidden = true;
86+
this.tooltip.setAttribute('role', 'dialog');
87+
this.tooltip.setAttribute('aria-live', 'polite');
88+
this.tooltip.setAttribute('aria-hidden', 'true');
89+
90+
this.root.append(this.header, this.track, this.empty, this.tooltip);
91+
92+
this.currentSegments = [];
93+
this.boundHideTooltip = this.hideTooltip.bind(this);
94+
}
95+
96+
update(segments, totalMinutes = 0) {
97+
this.hideTooltip();
98+
const hasSegments = Array.isArray(segments) && segments.length > 0 && totalMinutes > 0;
99+
this.track.innerHTML = '';
100+
this.currentSegments = hasSegments ? segments : [];
101+
this.total.textContent = hasSegments ? formatDuration(Math.round(totalMinutes)) : '—';
102+
this.track.hidden = !hasSegments;
103+
this.empty.hidden = hasSegments;
104+
if (!hasSegments) {
105+
return;
106+
}
107+
108+
segments.forEach((segment, index) => {
109+
const element = document.createElement('div');
110+
element.className = 'activity-share__segment';
111+
element.style.setProperty('--segment-color', segment.color || '#6366f1');
112+
element.style.setProperty('--segment-text-color', computeTextColor(segment.color));
113+
element.style.flexGrow = String(segment.minutes);
114+
element.setAttribute('role', 'listitem');
115+
const percentValue = Math.round(segment.percentage * 100);
116+
const labelText = this.getSegmentLabel(segment, percentValue);
117+
if (labelText) {
118+
const label = document.createElement('span');
119+
label.className = 'activity-share__segment-label';
120+
label.textContent = labelText;
121+
element.append(label);
122+
}
123+
element.setAttribute(
124+
'aria-label',
125+
`${segment.label}: ${formatDuration(Math.round(segment.minutes))} (${percentValue}%)`,
126+
);
127+
element.dataset.index = String(index);
128+
element.addEventListener('mouseenter', (event) => {
129+
this.showTooltip(event, segment);
130+
});
131+
element.addEventListener('mousemove', (event) => {
132+
this.positionTooltip(event);
133+
});
134+
element.addEventListener('mouseleave', this.boundHideTooltip);
135+
this.track.append(element);
136+
});
137+
}
138+
139+
getSegmentLabel(segment, percentValue) {
140+
if (segment.percentage >= 0.18) {
141+
return `${segment.label} · ${percentValue}%`;
142+
}
143+
if (segment.percentage >= 0.1) {
144+
return `${percentValue}%`;
145+
}
146+
return '';
147+
}
148+
149+
showTooltip(event, segment) {
150+
const percentValue = Math.round(segment.percentage * 100);
151+
const durationLabel = formatDuration(Math.round(segment.minutes));
152+
this.tooltip.innerHTML = `<strong>${segment.label}</strong><span>${durationLabel}</span><span>${percentValue}%</span>`;
153+
this.tooltip.hidden = false;
154+
this.tooltip.setAttribute('aria-hidden', 'false');
155+
this.positionTooltip(event);
156+
}
157+
158+
positionTooltip(event) {
159+
if (this.tooltip.hidden) {
160+
return;
161+
}
162+
const rootRect = this.root.getBoundingClientRect();
163+
const x = event.clientX - rootRect.left;
164+
const y = event.clientY - rootRect.top;
165+
this.tooltip.style.left = `${x}px`;
166+
this.tooltip.style.top = `${y}px`;
167+
}
168+
169+
hideTooltip() {
170+
this.tooltip.hidden = true;
171+
this.tooltip.setAttribute('aria-hidden', 'true');
172+
}
173+
}

web/ui/visuals/RadialUrchin.js

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
formatDuration,
55
minutesToTime,
66
} from './useUrchinLayout.js';
7+
import { ActivityShareBar, prepareActivityShareSegments } from './ActivityShareBar.js';
78
import { mapLabelToColor, resolveSurface, resolveStateLayer } from './palette.js';
89

910
const FULL_DAY_MINUTES = 24 * 60;
@@ -142,6 +143,8 @@ export class RadialUrchin {
142143

143144
this.metaElement = null;
144145
this.metaSlot = null;
146+
this.shareBar = null;
147+
this.shareContainer = null;
145148

146149
this.handleResize = this.handleResize.bind(this);
147150
this.handlePointerMove = this.handlePointerMove.bind(this);
@@ -307,6 +310,10 @@ export class RadialUrchin {
307310
this.exportPngButton,
308311
);
309312

313+
this.shareContainer = document.createElement('div');
314+
this.container.append(this.shareContainer);
315+
this.shareBar = new ActivityShareBar(this.shareContainer);
316+
310317
this.canvasWrapper = document.createElement('div');
311318
this.canvasWrapper.className = 'radial-urchin__stage visuals-canvas-block';
312319
this.container.append(this.canvasWrapper);
@@ -550,6 +557,7 @@ export class RadialUrchin {
550557
if (!this.layout) {
551558
this.displayArcs = [];
552559
this.visibleArcs = [];
560+
this.updateShareBar();
553561
return;
554562
}
555563
const zoom = this.getZoom();
@@ -589,6 +597,45 @@ export class RadialUrchin {
589597
return a.ringIndex - b.ringIndex;
590598
});
591599
this.displayMaxRadius = (this.layout?.maxRadius || 160) * scale;
600+
this.updateShareBar();
601+
}
602+
603+
computeVisibleActivityTotals() {
604+
if (!Array.isArray(this.visibleArcs) || this.visibleArcs.length === 0) {
605+
return [];
606+
}
607+
const totals = new Map();
608+
this.visibleArcs.forEach((arc) => {
609+
const duration = Number.isFinite(arc.segmentDuration)
610+
? arc.segmentDuration
611+
: Number.isFinite(arc.duration)
612+
? arc.duration
613+
: 0;
614+
if (!(duration > 0)) {
615+
return;
616+
}
617+
const label = arc.label || arc.event?.activity || 'Activity';
618+
if (!totals.has(label)) {
619+
totals.set(label, {
620+
id: label,
621+
label,
622+
minutes: 0,
623+
color: arc.color || mapLabelToColor(label, { highContrast: this.state.highContrast }),
624+
});
625+
}
626+
const entry = totals.get(label);
627+
entry.minutes += duration;
628+
});
629+
return Array.from(totals.values()).sort((a, b) => b.minutes - a.minutes);
630+
}
631+
632+
updateShareBar() {
633+
if (!this.shareBar) {
634+
return;
635+
}
636+
const totals = this.computeVisibleActivityTotals();
637+
const { segments, totalMinutes } = prepareActivityShareSegments(totals);
638+
this.shareBar.update(segments, totalMinutes);
592639
}
593640

594641
computeSegments(arc, zoom) {

0 commit comments

Comments
 (0)