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
110 changes: 110 additions & 0 deletions web/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -1171,6 +1171,116 @@ body {
align-items: flex-start;
}

.radial-urchin__share {
position: relative;
display: flex;
flex-direction: column;
gap: 10px;
padding: 16px 18px;
background: rgba(15, 23, 42, 0.65);
border: 1px solid rgba(148, 163, 184, 0.2);
border-radius: 18px;
box-shadow: 0 18px 32px rgba(15, 23, 42, 0.32);
}

.activity-share__header {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
font-weight: 600;
color: #cbd5f5;
text-transform: uppercase;
letter-spacing: 0.08em;
}

.activity-share__title {
color: #e2e8f0;
}

.activity-share__total {
font-size: 11px;
font-weight: 500;
color: rgba(148, 163, 184, 0.85);
}

.activity-share__track {
display: flex;
border-radius: 14px;
overflow: hidden;
min-height: 32px;
border: 1px solid rgba(148, 163, 184, 0.28);
background: rgba(148, 163, 184, 0.12);
box-shadow: inset 0 0 0 1px rgba(15, 23, 42, 0.25);
}

.activity-share__segment {
flex: 1 1 0%;
display: flex;
align-items: center;
justify-content: center;
position: relative;
padding: 4px 8px;
min-width: 6px;
background: var(--segment-color, #6366f1);
color: var(--segment-text-color, #0f172a);
transition: transform 0.18s ease;
cursor: pointer;
}

.activity-share__segment:hover {
transform: translateY(-1px);
}

.activity-share__segment-label {
font-size: 12px;
font-weight: 600;
text-shadow: 0 1px 2px rgba(15, 23, 42, 0.35);
white-space: nowrap;
}

.activity-share__empty {
margin: 0;
font-size: 12px;
color: rgba(148, 163, 184, 0.85);
}

.activity-share__tooltip {
position: absolute;
transform: translate(-50%, -100%) translateY(-12px);
background: rgba(15, 23, 42, 0.92);
border-radius: 12px;
padding: 10px 12px;
border: 1px solid rgba(148, 163, 184, 0.4);
box-shadow: 0 16px 28px rgba(15, 23, 42, 0.45);
display: flex;
flex-direction: column;
gap: 4px;
font-size: 12px;
pointer-events: none;
color: #f8fafc;
z-index: 2;
}

.activity-share__tooltip strong {
font-size: 13px;
font-weight: 700;
color: #e0e7ff;
}

.activity-share__tooltip span {
display: block;
color: rgba(226, 232, 240, 0.92);
}

.activity-share__track[hidden] {
display: none;
}

.activity-share__tooltip[hidden] {
display: none;
}

.radial-urchin__legend-empty {
margin: 0;
color: #94a3b8;
Expand Down
173 changes: 173 additions & 0 deletions web/ui/visuals/ActivityShareBar.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import { formatDuration } from './useUrchinLayout.js';

function toHexChannel(value) {
const channel = Number.parseInt(value, 16);
if (Number.isNaN(channel)) {
return 0;
}
return channel;
}

function computeTextColor(background) {
if (typeof background !== 'string') {
return '#0f172a';
}
let hex = background.trim();
if (hex.startsWith('#')) {
hex = hex.slice(1);
}
if (hex.length === 3) {
hex = hex
.split('')
.map((char) => char + char)
.join('');
}
if (hex.length !== 6) {
return '#0f172a';
}
const r = toHexChannel(hex.slice(0, 2));
const g = toHexChannel(hex.slice(2, 4));
const b = toHexChannel(hex.slice(4, 6));
const luminance = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
return luminance > 0.6 ? '#0f172a' : '#f8fafc';
}

export function prepareActivityShareSegments(activities) {
const filtered = Array.isArray(activities)
? activities.filter((activity) => Number.isFinite(activity?.minutes) && activity.minutes > 0)
: [];
const totalMinutes = filtered.reduce((sum, activity) => sum + activity.minutes, 0);
if (totalMinutes <= 0) {
return { totalMinutes: 0, segments: [] };
}
const segments = filtered.map((activity) => ({
id: activity.id ?? activity.label,
label: activity.label,
minutes: activity.minutes,
color: activity.color,
percentage: activity.minutes / totalMinutes,
}));
return { totalMinutes, segments };
}

export class ActivityShareBar {
constructor(container) {
this.root = container;
this.root.classList.add('radial-urchin__share');
this.root.setAttribute('role', 'group');
this.root.setAttribute('aria-label', 'Activity balance overview');

this.header = document.createElement('div');
this.header.className = 'activity-share__header';
this.title = document.createElement('span');
this.title.className = 'activity-share__title';
this.title.textContent = 'Activity balance';
this.total = document.createElement('span');
this.total.className = 'activity-share__total';
this.total.textContent = '—';
this.header.append(this.title, this.total);

this.track = document.createElement('div');
this.track.className = 'activity-share__track';
this.track.setAttribute('role', 'list');
this.track.hidden = true;
this.track.addEventListener('mouseleave', () => {
this.hideTooltip();
});

this.empty = document.createElement('p');
this.empty.className = 'activity-share__empty';
this.empty.textContent = 'Select activities to see their balance.';
this.empty.hidden = false;

this.tooltip = document.createElement('div');
this.tooltip.className = 'activity-share__tooltip';
this.tooltip.hidden = true;
this.tooltip.setAttribute('role', 'dialog');
this.tooltip.setAttribute('aria-live', 'polite');
this.tooltip.setAttribute('aria-hidden', 'true');

this.root.append(this.header, this.track, this.empty, this.tooltip);

this.currentSegments = [];
this.boundHideTooltip = this.hideTooltip.bind(this);
}

update(segments, totalMinutes = 0) {
this.hideTooltip();
const hasSegments = Array.isArray(segments) && segments.length > 0 && totalMinutes > 0;
this.track.innerHTML = '';
this.currentSegments = hasSegments ? segments : [];
this.total.textContent = hasSegments ? formatDuration(Math.round(totalMinutes)) : '—';
this.track.hidden = !hasSegments;
this.empty.hidden = hasSegments;
if (!hasSegments) {
return;
}

segments.forEach((segment, index) => {
const element = document.createElement('div');
element.className = 'activity-share__segment';
element.style.setProperty('--segment-color', segment.color || '#6366f1');
element.style.setProperty('--segment-text-color', computeTextColor(segment.color));
element.style.flexGrow = String(segment.minutes);
element.setAttribute('role', 'listitem');
const percentValue = Math.round(segment.percentage * 100);
const labelText = this.getSegmentLabel(segment, percentValue);
if (labelText) {
const label = document.createElement('span');
label.className = 'activity-share__segment-label';
label.textContent = labelText;
element.append(label);
}
element.setAttribute(
'aria-label',
`${segment.label}: ${formatDuration(Math.round(segment.minutes))} (${percentValue}%)`,
);
element.dataset.index = String(index);
element.addEventListener('mouseenter', (event) => {
this.showTooltip(event, segment);
});
element.addEventListener('mousemove', (event) => {
this.positionTooltip(event);
});
element.addEventListener('mouseleave', this.boundHideTooltip);
this.track.append(element);
});
}

getSegmentLabel(segment, percentValue) {
if (segment.percentage >= 0.18) {
return `${segment.label} · ${percentValue}%`;
}
if (segment.percentage >= 0.1) {
return `${percentValue}%`;
}
return '';
}

showTooltip(event, segment) {
const percentValue = Math.round(segment.percentage * 100);
const durationLabel = formatDuration(Math.round(segment.minutes));
this.tooltip.innerHTML = `<strong>${segment.label}</strong><span>${durationLabel}</span><span>${percentValue}%</span>`;
this.tooltip.hidden = false;
this.tooltip.setAttribute('aria-hidden', 'false');
this.positionTooltip(event);
}

positionTooltip(event) {
if (this.tooltip.hidden) {
return;
}
const rootRect = this.root.getBoundingClientRect();
const x = event.clientX - rootRect.left;
const y = event.clientY - rootRect.top;
this.tooltip.style.left = `${x}px`;
this.tooltip.style.top = `${y}px`;
}

hideTooltip() {
this.tooltip.hidden = true;
this.tooltip.setAttribute('aria-hidden', 'true');
}
}
47 changes: 47 additions & 0 deletions web/ui/visuals/RadialUrchin.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
formatDuration,
minutesToTime,
} from './useUrchinLayout.js';
import { ActivityShareBar, prepareActivityShareSegments } from './ActivityShareBar.js';
import { mapLabelToColor, resolveSurface, resolveStateLayer } from './palette.js';

const FULL_DAY_MINUTES = 24 * 60;
Expand Down Expand Up @@ -142,6 +143,8 @@ export class RadialUrchin {

this.metaElement = null;
this.metaSlot = null;
this.shareBar = null;
this.shareContainer = null;

this.handleResize = this.handleResize.bind(this);
this.handlePointerMove = this.handlePointerMove.bind(this);
Expand Down Expand Up @@ -307,6 +310,10 @@ export class RadialUrchin {
this.exportPngButton,
);

this.shareContainer = document.createElement('div');
this.container.append(this.shareContainer);
this.shareBar = new ActivityShareBar(this.shareContainer);

this.canvasWrapper = document.createElement('div');
this.canvasWrapper.className = 'radial-urchin__stage visuals-canvas-block';
this.container.append(this.canvasWrapper);
Expand Down Expand Up @@ -550,6 +557,7 @@ export class RadialUrchin {
if (!this.layout) {
this.displayArcs = [];
this.visibleArcs = [];
this.updateShareBar();
return;
}
const zoom = this.getZoom();
Expand Down Expand Up @@ -589,6 +597,45 @@ export class RadialUrchin {
return a.ringIndex - b.ringIndex;
});
this.displayMaxRadius = (this.layout?.maxRadius || 160) * scale;
this.updateShareBar();
}

computeVisibleActivityTotals() {
if (!Array.isArray(this.visibleArcs) || this.visibleArcs.length === 0) {
return [];
}
const totals = new Map();
this.visibleArcs.forEach((arc) => {
const duration = Number.isFinite(arc.segmentDuration)
? arc.segmentDuration
: Number.isFinite(arc.duration)
? arc.duration
: 0;
if (!(duration > 0)) {
return;
}
const label = arc.label || arc.event?.activity || 'Activity';
if (!totals.has(label)) {
totals.set(label, {
id: label,
label,
minutes: 0,
color: arc.color || mapLabelToColor(label, { highContrast: this.state.highContrast }),
});
}
const entry = totals.get(label);
entry.minutes += duration;
});
return Array.from(totals.values()).sort((a, b) => b.minutes - a.minutes);
}

updateShareBar() {
if (!this.shareBar) {
return;
}
const totals = this.computeVisibleActivityTotals();
const { segments, totalMinutes } = prepareActivityShareSegments(totals);
this.shareBar.update(segments, totalMinutes);
}

computeSegments(arc, zoom) {
Expand Down
Loading