Updates and additions
This commit is contained in:
parent
921f7cbf93
commit
7c160fee60
|
|
@ -119,6 +119,7 @@ class KpiChart
|
|||
'title' => $entry->entry_date->format('M Y') . ': '
|
||||
. $kpi->value_type->format($entry->value, $kpi->unit_label)
|
||||
. ' — ' . $status->label(),
|
||||
'note' => $entry->notes,
|
||||
];
|
||||
$current[] = [$px, $py];
|
||||
}
|
||||
|
|
@ -239,6 +240,7 @@ class KpiChart
|
|||
'color' => $met ? self::GOOD : self::CRIT,
|
||||
'glyph' => $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
|
|
@ -85,6 +85,70 @@
|
|||
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) {
|
||||
.detail-outer,
|
||||
.detail-inner,
|
||||
|
|
@ -97,4 +161,17 @@
|
|||
.is-open .kpi-pop {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,13 +32,13 @@
|
|||
</tr>
|
||||
@foreach ($rows as $row)
|
||||
@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">
|
||||
@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>
|
||||
@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>
|
||||
@endif
|
||||
</td>
|
||||
|
|
@ -89,9 +89,16 @@
|
|||
</div>
|
||||
<p class="mt-2 text-xs text-gray-400">
|
||||
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>
|
||||
|
||||
{{-- 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>
|
||||
(function () {
|
||||
function expandRow(row) {
|
||||
|
|
@ -169,9 +176,9 @@
|
|||
}
|
||||
|
||||
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');
|
||||
if (kpiRow && !e.target.closest('a')) {
|
||||
if (kpiRow) {
|
||||
var kpiDetail = kpiRow.nextElementSibling;
|
||||
var kpiOpen = kpiDetail && kpiDetail.classList.contains('is-open');
|
||||
collapseAll();
|
||||
|
|
@ -200,11 +207,151 @@
|
|||
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.
|
||||
if (!e.target.closest('.js-detail-row')) {
|
||||
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>
|
||||
@endif
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
{{-- Expects: $kpi (App\Models\Kpi), $chart (array from App\Support\KpiChart::build) --}}
|
||||
<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>
|
||||
@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>
|
||||
|
||||
@if (($chart['type'] === 'line' && empty($chart['points'])) || ($chart['type'] === 'milestone' && empty($chart['markers'])))
|
||||
|
|
@ -37,12 +40,23 @@
|
|||
stroke-linecap="round" stroke-linejoin="round" />
|
||||
@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)
|
||||
<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">
|
||||
<title>{{ $point['title'] }}</title>
|
||||
</circle>
|
||||
@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 }}"
|
||||
fill="{{ $point['color'] }}" stroke="#ffffff" stroke-width="2">
|
||||
<title>{{ $point['title'] }}</title>
|
||||
</circle>
|
||||
@endif
|
||||
@endforeach
|
||||
|
||||
{{-- End value label (selective direct label) --}}
|
||||
|
|
@ -64,13 +78,24 @@
|
|||
<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" />
|
||||
@foreach ($chart['markers'] as $marker)
|
||||
<g class="kpi-pop" style="--i: {{ $loop->index }}">
|
||||
<title>{{ $marker['title'] }}</title>
|
||||
<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>
|
||||
@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 }}">
|
||||
<title>{{ $marker['title'] }}</title>
|
||||
<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>
|
||||
@endif
|
||||
@endforeach
|
||||
@foreach ($chart['monthLabels'] as $label)
|
||||
<text x="{{ $label['x'] }}" y="{{ $label['y'] }}" text-anchor="middle"
|
||||
|
|
|
|||
|
|
@ -4,7 +4,10 @@
|
|||
|
||||
@section('content')
|
||||
<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>
|
||||
|
||||
@guest
|
||||
|
|
|
|||
Loading…
Reference in New Issue