Updates and additions

This commit is contained in:
Brian Fertig 2026-07-10 12:52:17 -06:00
parent 921f7cbf93
commit 7c160fee60
7 changed files with 274 additions and 20 deletions

View File

@ -119,6 +119,7 @@ class KpiChart
'title' => $entry->entry_date->format('M Y') . ': ' 'title' => $entry->entry_date->format('M Y') . ': '
. $kpi->value_type->format($entry->value, $kpi->unit_label) . $kpi->value_type->format($entry->value, $kpi->unit_label)
. ' — ' . $status->label(), . ' — ' . $status->label(),
'note' => $entry->notes,
]; ];
$current[] = [$px, $py]; $current[] = [$px, $py];
} }
@ -239,6 +240,7 @@ class KpiChart
'color' => $met ? self::GOOD : self::CRIT, 'color' => $met ? self::GOOD : self::CRIT,
'glyph' => $met ? '✓' : '✕', 'glyph' => $met ? '✓' : '✕',
'title' => $entry->entry_date->format('M Y') . ': ' . ($met ? 'Met' : 'Not met'), 'title' => $entry->entry_date->format('M Y') . ': ' . ($met ? 'Met' : 'Not met'),
'note' => $entry->notes,
]; ];
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

File diff suppressed because one or more lines are too long

View File

@ -85,6 +85,70 @@
animation-delay: calc(250ms + var(--i, 0) * 40ms); animation-delay: calc(250ms + var(--i, 0) * 40ms);
} }
/* --- Noted chart points + floating note tooltip ------------------------ */
/* Points whose entry carries a note get a theme-colored halo that gently
pulses (identity marker, not state RAG fills stay untouched). Hovering
with intent draws a leader line to a fixed-position floating panel. */
.kpi-note-ring {
fill: none;
stroke: var(--color-primary);
stroke-width: 1.5;
transform-box: fill-box;
transform-origin: center;
}
@keyframes kpi-note-pulse {
0%, 100% { stroke-opacity: 0.85; transform: scale(1); }
50% { stroke-opacity: 0.35; transform: scale(1.35); }
}
.is-open .kpi-note-ring {
/* Starts after the pop-in stagger settles. */
animation: kpi-note-pulse 2.2s ease-in-out 900ms infinite;
}
.js-note-point { cursor: pointer; }
/* Fixed positioning escapes .detail-inner's overflow: hidden and the
table's overflow-x-auto wrapper. */
.kpi-note-tooltip {
position: fixed;
z-index: 50;
max-width: 400px;
opacity: 0;
transform: scale(0.96);
pointer-events: none;
transition: opacity 160ms ease-out, transform 160ms cubic-bezier(0.34, 1.4, 0.64, 1);
}
.kpi-note-tooltip.is-visible {
opacity: 1;
transform: scale(1);
pointer-events: auto; /* lets users move in and scroll long notes */
}
/* Full-viewport overlay carrying the point-to-panel leader line. */
.kpi-note-leader {
position: fixed;
inset: 0;
width: 100%;
height: 100%;
z-index: 49;
pointer-events: none;
}
.kpi-note-leader line {
stroke: var(--color-primary);
stroke-width: 1.5;
stroke-dasharray: 1; /* pathLength="1" in markup */
stroke-dashoffset: 1;
}
.kpi-note-leader.is-visible line {
animation: kpi-draw 220ms ease-out forwards;
}
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.detail-outer, .detail-outer,
.detail-inner, .detail-inner,
@ -97,4 +161,17 @@
.is-open .kpi-pop { .is-open .kpi-pop {
animation: none; animation: none;
} }
.is-open .kpi-note-ring {
animation: none; /* halo stays as a static marker */
}
.kpi-note-tooltip {
transition: none;
}
.kpi-note-leader.is-visible line {
animation: none;
stroke-dashoffset: 0;
}
} }

View File

@ -32,13 +32,13 @@
</tr> </tr>
@foreach ($rows as $row) @foreach ($rows as $row)
@php($item = $row['item']) @php($item = $row['item'])
<tr class="{{ $row['type'] === 'kpi' ? 'js-kpi-row cursor-pointer hover:bg-gray-50' : '' }}"> <tr class="{{ $row['type'] === 'kpi' ? 'js-kpi-row' : 'js-project-row' }} cursor-pointer hover:bg-gray-50">
<td class="sticky left-0 z-10 bg-white px-4 py-2 font-medium"> <td class="sticky left-0 z-10 bg-white px-4 py-2 font-medium">
@if ($row['type'] === 'kpi') @if ($row['type'] === 'kpi')
<a href="{{ route('kpis.entries.index', $item) }}" class="hover:underline">{{ $item->name }}</a> {{ $item->name }}
<span class="block text-xs font-normal text-gray-400">{{ $item->thresholdSummary() }}</span> <span class="block text-xs font-normal text-gray-400">{{ $item->thresholdSummary() }}</span>
@else @else
<a href="{{ route('projects.entries.index', $item) }}" class="hover:underline">{{ $item->name }}</a> {{ $item->name }}
<span class="block text-xs font-normal text-gray-400">Project</span> <span class="block text-xs font-normal text-gray-400">Project</span>
@endif @endif
</td> </td>
@ -89,9 +89,16 @@
</div> </div>
<p class="mt-2 text-xs text-gray-400"> <p class="mt-2 text-xs text-gray-400">
Each cell shows the most recent entry in that month. The current month is highlighted. Each cell shows the most recent entry in that month. The current month is highlighted.
Click a KPI row to expand its trend chart; click a project's cell to expand that month's details. Click a KPI row to expand its trend chart; click a project row for its latest details, or a specific cell for that month.
</p> </p>
{{-- Floating note tooltip + leader line (one singleton shared by every chart on the page) --}}
<div id="kpi-note-tooltip" class="kpi-note-tooltip rounded-md border border-gray-200 bg-white shadow-lg" role="tooltip" aria-hidden="true">
<p class="border-b border-gray-100 px-3 py-1.5 text-xs font-semibold text-gray-900" data-role="title"></p>
<p class="max-h-[50vh] overflow-y-auto whitespace-pre-line px-3 py-2 text-xs text-gray-700" data-role="note"></p>
</div>
<svg class="kpi-note-leader" aria-hidden="true"><line x1="0" y1="0" x2="0" y2="0" pathLength="1" /></svg>
<script> <script>
(function () { (function () {
function expandRow(row) { function expandRow(row) {
@ -169,9 +176,9 @@
} }
document.addEventListener('click', function (e) { document.addEventListener('click', function (e) {
// KPI rows expand to a threshold chart (but let the entries link navigate). // KPI rows expand to a threshold chart.
var kpiRow = e.target.closest('.js-kpi-row'); var kpiRow = e.target.closest('.js-kpi-row');
if (kpiRow && !e.target.closest('a')) { if (kpiRow) {
var kpiDetail = kpiRow.nextElementSibling; var kpiDetail = kpiRow.nextElementSibling;
var kpiOpen = kpiDetail && kpiDetail.classList.contains('is-open'); var kpiOpen = kpiDetail && kpiDetail.classList.contains('is-open');
collapseAll(); collapseAll();
@ -200,11 +207,151 @@
return; return;
} }
// Project rows (outside a month cell) toggle the most recent month's details.
var projRow = e.target.closest('.js-project-row');
if (projRow) {
var projDetail = projRow.nextElementSibling;
var projOpen = projDetail && projDetail.classList.contains('is-open');
collapseAll();
if (projDetail && !projOpen) {
var cells = projRow.querySelectorAll('.js-project-cell');
var latest = cells.length ? cells[cells.length - 1] : null;
if (latest) {
populate(projDetail.querySelector('.js-detail-content'), JSON.parse(latest.dataset.details));
projDetail.dataset.month = latest.dataset.month;
expandRow(projDetail);
latest.classList.add('ring-2', 'ring-secondary');
}
}
return;
}
// Clicks inside an open detail row keep it open; anywhere else collapses. // Clicks inside an open detail row keep it open; anywhere else collapses.
if (!e.target.closest('.js-detail-row')) { if (!e.target.closest('.js-detail-row')) {
collapseAll(); collapseAll();
} }
}); });
})(); })();
// Note tooltips on chart points: hover intent shows a floating panel
// with a leader line drawn from the point. One singleton, delegated
// listeners — charts appear/disappear with row expansion unwired.
(function () {
var tip = document.getElementById('kpi-note-tooltip');
if (!tip) return;
var leader = document.querySelector('.kpi-note-leader');
var leaderLine = leader.querySelector('line');
var titleEl = tip.querySelector('[data-role="title"]');
var noteEl = tip.querySelector('[data-role="note"]');
var showTimer = null, hideTimer = null, activePoint = null;
var SHOW_DELAY = 250, HIDE_DELAY = 200, GAP = 46;
function pointCenter(group) {
var r = group.querySelector('.js-note-hit, circle').getBoundingClientRect();
return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
}
function show(group) {
activePoint = group;
titleEl.textContent = group.dataset.title || '';
noteEl.textContent = group.dataset.note || '';
// Measure at origin, then place.
tip.style.left = '0px';
tip.style.top = '0px';
var p = pointCenter(group);
var tw = tip.offsetWidth, th = tip.offsetHeight;
// Default: panel up-and-right of the point; flip at viewport edges.
var dirX = (p.x + GAP + tw > window.innerWidth - 8) ? -1 : 1;
var dirY = (p.y - GAP - th < 8) ? 1 : -1;
var left = dirX === 1 ? p.x + GAP : p.x - GAP - tw;
var top = dirY === -1 ? p.y - GAP - th : p.y + GAP;
left = Math.max(8, Math.min(left, window.innerWidth - tw - 8));
top = Math.max(8, Math.min(top, window.innerHeight - th - 8));
tip.style.left = left + 'px';
tip.style.top = top + 'px';
// Leader runs from the point to the panel corner nearest it.
leaderLine.setAttribute('x1', p.x);
leaderLine.setAttribute('y1', p.y);
leaderLine.setAttribute('x2', dirX === 1 ? left : left + tw);
leaderLine.setAttribute('y2', dirY === -1 ? top + th : top);
leader.classList.remove('is-visible');
void leader.offsetWidth; // restart the draw-in animation
leader.classList.add('is-visible');
tip.classList.add('is-visible');
tip.setAttribute('aria-hidden', 'false');
}
function hide() {
clearTimeout(showTimer);
clearTimeout(hideTimer);
showTimer = hideTimer = null;
activePoint = null;
tip.classList.remove('is-visible');
leader.classList.remove('is-visible');
tip.setAttribute('aria-hidden', 'true');
}
function scheduleHide() {
clearTimeout(hideTimer);
hideTimer = setTimeout(hide, HIDE_DELAY);
}
// Hover intent via delegated mouseover/mouseout (they bubble; mouseenter doesn't).
document.addEventListener('mouseover', function (e) {
var group = e.target.closest && e.target.closest('.js-note-point');
if (group) {
clearTimeout(hideTimer);
if (group === activePoint) return;
clearTimeout(showTimer);
showTimer = setTimeout(function () { show(group); }, SHOW_DELAY);
return;
}
if (e.target.closest && e.target.closest('#kpi-note-tooltip')) {
clearTimeout(hideTimer); // pointer moved into the panel: keep it
}
});
document.addEventListener('mouseout', function (e) {
var from = e.target.closest && e.target.closest('.js-note-point, #kpi-note-tooltip');
if (!from) return;
var to = e.relatedTarget && e.relatedTarget.closest
&& e.relatedTarget.closest('.js-note-point, #kpi-note-tooltip');
if (to === from || (to && activePoint && to === activePoint)) return;
clearTimeout(showTimer);
showTimer = null;
if (activePoint) scheduleHide();
});
// Touch/click fallback: tapping a noted point toggles the tooltip.
// Capture phase so dismissal runs even if another handler stops propagation.
document.addEventListener('click', function (e) {
var group = e.target.closest && e.target.closest('.js-note-point');
if (group) {
e.stopPropagation(); // keep the grid's collapse handler out of it
clearTimeout(showTimer);
if (activePoint === group) {
hide();
} else {
show(group);
}
return;
}
if (!e.target.closest('#kpi-note-tooltip')) hide();
}, true);
// Fixed coordinates go stale on any scroll or resize: hide immediately.
// Scrolls inside the panel's own note body are exempt — that's the
// user reading a long note, not the page moving under the tooltip.
window.addEventListener('scroll', function (e) {
if (e.target instanceof Node && tip.contains(e.target)) return;
hide();
}, true);
window.addEventListener('resize', hide);
})();
</script> </script>
@endif @endif

View File

@ -1,6 +1,9 @@
{{-- Expects: $kpi (App\Models\Kpi), $chart (array from App\Support\KpiChart::build) --}} {{-- Expects: $kpi (App\Models\Kpi), $chart (array from App\Support\KpiChart::build) --}}
<div class="rounded-md bg-white p-4"> <div class="rounded-md bg-white p-4">
<p class="mb-1 text-sm font-semibold text-gray-900">{{ $kpi->name }} last 12 months</p> <p class="mb-1 text-sm font-semibold text-gray-900">{{ $kpi->name }} last 12 months</p>
@if ($kpi->description)
<p class="mb-2 max-w-3xl whitespace-pre-line text-xs text-gray-600">{{ $kpi->description }}</p>
@endif
<p class="mb-3 text-xs text-gray-500">{{ $kpi->thresholdSummary() }}@if ($kpi->unit_label) · unit: {{ $kpi->unit_label }}@endif</p> <p class="mb-3 text-xs text-gray-500">{{ $kpi->thresholdSummary() }}@if ($kpi->unit_label) · unit: {{ $kpi->unit_label }}@endif</p>
@if (($chart['type'] === 'line' && empty($chart['points'])) || ($chart['type'] === 'milestone' && empty($chart['markers']))) @if (($chart['type'] === 'line' && empty($chart['points'])) || ($chart['type'] === 'milestone' && empty($chart['markers'])))
@ -37,12 +40,23 @@
stroke-linecap="round" stroke-linejoin="round" /> stroke-linecap="round" stroke-linejoin="round" />
@endforeach @endforeach
{{-- Points, status-colored with a 2px surface ring --}} {{-- Points, status-colored with a 2px surface ring. Noted points get a
pulsing halo and a custom hover tooltip instead of the native <title>. --}}
@foreach ($chart['points'] as $point) @foreach ($chart['points'] as $point)
@if (!empty($point['note']))
<g class="kpi-pop js-note-point" style="--i: {{ $loop->index }}"
data-note="{{ $point['note'] }}" data-title="{{ $point['title'] }}">
<circle cx="{{ $point['x'] }}" cy="{{ $point['y'] }}" r="8" class="kpi-note-ring" />
<circle cx="{{ $point['x'] }}" cy="{{ $point['y'] }}" r="4.5"
fill="{{ $point['color'] }}" stroke="#ffffff" stroke-width="2" />
<circle cx="{{ $point['x'] }}" cy="{{ $point['y'] }}" r="12" fill="transparent" class="js-note-hit" />
</g>
@else
<circle cx="{{ $point['x'] }}" cy="{{ $point['y'] }}" r="4.5" class="kpi-pop" style="--i: {{ $loop->index }}" <circle cx="{{ $point['x'] }}" cy="{{ $point['y'] }}" r="4.5" class="kpi-pop" style="--i: {{ $loop->index }}"
fill="{{ $point['color'] }}" stroke="#ffffff" stroke-width="2"> fill="{{ $point['color'] }}" stroke="#ffffff" stroke-width="2">
<title>{{ $point['title'] }}</title> <title>{{ $point['title'] }}</title>
</circle> </circle>
@endif
@endforeach @endforeach
{{-- End value label (selective direct label) --}} {{-- End value label (selective direct label) --}}
@ -64,6 +78,16 @@
<line x1="{{ $chart['plot']['x'] }}" y1="{{ $chart['baseline'] }}" x2="{{ $chart['plot']['x'] + $chart['plot']['w'] }}" y2="{{ $chart['baseline'] }}" <line x1="{{ $chart['plot']['x'] }}" y1="{{ $chart['baseline'] }}" x2="{{ $chart['plot']['x'] + $chart['plot']['w'] }}" y2="{{ $chart['baseline'] }}"
stroke="{{ \App\Support\KpiChart::GRIDLINE }}" stroke-width="1" /> stroke="{{ \App\Support\KpiChart::GRIDLINE }}" stroke-width="1" />
@foreach ($chart['markers'] as $marker) @foreach ($chart['markers'] as $marker)
@if (!empty($marker['note']))
<g class="kpi-pop js-note-point" style="--i: {{ $loop->index }}"
data-note="{{ $marker['note'] }}" data-title="{{ $marker['title'] }}">
<circle cx="{{ $marker['x'] }}" cy="{{ $marker['y'] }}" r="13" class="kpi-note-ring" />
<circle cx="{{ $marker['x'] }}" cy="{{ $marker['y'] }}" r="9"
fill="{{ $marker['color'] }}" stroke="#ffffff" stroke-width="2" />
<text x="{{ $marker['x'] }}" y="{{ $marker['y'] + 3.5 }}" text-anchor="middle"
font-size="10" font-weight="700" fill="#ffffff">{{ $marker['glyph'] }}</text>
</g>
@else
<g class="kpi-pop" style="--i: {{ $loop->index }}"> <g class="kpi-pop" style="--i: {{ $loop->index }}">
<title>{{ $marker['title'] }}</title> <title>{{ $marker['title'] }}</title>
<circle cx="{{ $marker['x'] }}" cy="{{ $marker['y'] }}" r="9" <circle cx="{{ $marker['x'] }}" cy="{{ $marker['y'] }}" r="9"
@ -71,6 +95,7 @@
<text x="{{ $marker['x'] }}" y="{{ $marker['y'] + 3.5 }}" text-anchor="middle" <text x="{{ $marker['x'] }}" y="{{ $marker['y'] + 3.5 }}" text-anchor="middle"
font-size="10" font-weight="700" fill="#ffffff">{{ $marker['glyph'] }}</text> font-size="10" font-weight="700" fill="#ffffff">{{ $marker['glyph'] }}</text>
</g> </g>
@endif
@endforeach @endforeach
@foreach ($chart['monthLabels'] as $label) @foreach ($chart['monthLabels'] as $label)
<text x="{{ $label['x'] }}" y="{{ $label['y'] }}" text-anchor="middle" <text x="{{ $label['x'] }}" y="{{ $label['y'] }}" text-anchor="middle"

View File

@ -4,7 +4,10 @@
@section('content') @section('content')
<div class="rounded-lg border border-gray-200 bg-white p-10 text-center"> <div class="rounded-lg border border-gray-200 bg-white p-10 text-center">
<h1 class="text-4xl font-bold tracking-tight">Hello World</h1> <h1>
<img src="{{ asset('assets/images/bowler-charts-logo.png') }}" alt="Bowler Charts"
class="mx-auto w-full max-w-xs" width="1024" height="1024">
</h1>
<p class="mt-4 text-gray-600">Welcome to {{ config('app.name', 'Bowler') }} shared bowler charts for tracking project status and KPIs.</p> <p class="mt-4 text-gray-600">Welcome to {{ config('app.name', 'Bowler') }} shared bowler charts for tracking project status and KPIs.</p>
@guest @guest