polish: appearance menu offsets, anon map badge, bar label overflow (v0.2.4)

- appearance menu opens 20px below the header/chips and 58px from the right edge
- map "no coordinates" badge is manager-only (hidden for anonymous visitors)
- timeline bar label that is wider than its bar now renders just past the bar's
  right edge as dark text on the track, instead of being clipped; labels that
  fit still sit inside the bar in white. Applied post-render (measure overflow),
  re-run on changed/rangechanged with a double rAF so bar widths are settled.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
aaverbitskiy 2026-08-14 19:09:47 +00:00
parent 159590d62e
commit 9b6ca6f848
5 changed files with 53 additions and 12 deletions

View File

@ -1,7 +1,7 @@
{
"name": "mapdash-frontend",
"private": true,
"version": "0.2.3",
"version": "0.2.4",
"type": "module",
"scripts": {
"dev": "vite",

View File

@ -190,7 +190,7 @@ function positionDecorMenu(): void {
if (chips && chips.style.display !== 'none' && chips.getClientRects().length) {
refBottom = Math.max(refBottom, chips.getBoundingClientRect().bottom);
}
const top = refBottom + 25;
const top = refBottom + 20;
el.decorMenu.style.top = top + 'px';
el.decorMenu.style.maxHeight = Math.max(120, window.innerHeight - top - 30) + 'px';
}
@ -370,7 +370,7 @@ async function refreshMap(): Promise<void> {
if (key === mapLoadedKey) return;
mapLoadedKey = key;
const surfaces = await api.map(currentFilters());
mapView.render(surfaces);
mapView.render(surfaces, isManager());
} catch (e) {
console.error('map load failed', e);
} finally {

View File

@ -59,7 +59,7 @@ export interface MapElements {
}
export interface MapView {
render(surfaces: MapSurface[]): void;
render(surfaces: MapSurface[], managerView: boolean): void;
ensureInit(): Promise<void>;
invalidateSize(): void;
}
@ -203,6 +203,10 @@ export function createMapView(els: MapElements, apiKey: string): MapView {
tip.style.display = 'none';
}
// Whether the current viewer is a manager. The "no coordinates" badge is a
// manager-only tool, so it stays hidden for anonymous visitors.
let managerView = false;
function draw(surfaces: MapSurface[]): void {
if (!objectManager) return;
const withCoords = surfaces.filter((s) => s.lat != null && s.lon != null);
@ -233,14 +237,14 @@ export function createMapView(els: MapElements, apiKey: string): MapView {
// (which can't be drawn but are still surfaces — listed under "без координат").
els.counter.textContent = `всего: ${surfaces.length}`;
// Actionable "no coordinates" badge + list.
// Actionable "no coordinates" badge + list — manager-only.
if (noCoordsBtn) {
noCoordsBtn.style.display = noCoordsList.length ? '' : 'none';
noCoordsBtn.style.display = managerView && noCoordsList.length ? '' : 'none';
noCoordsBtn.textContent = `без координат: ${noCoordsList.length}`;
}
if (noCoordsPanel) {
noCoordsPanel.innerHTML = renderNoCoords(noCoordsList);
if (!noCoordsList.length) noCoordsPanel.style.display = 'none';
if (!managerView || !noCoordsList.length) noCoordsPanel.style.display = 'none';
}
els.empty.style.display = withCoords.length ? 'none' : 'flex';
@ -258,7 +262,8 @@ export function createMapView(els: MapElements, apiKey: string): MapView {
}
return {
render(surfaces: MapSurface[]): void {
render(surfaces: MapSurface[], mv: boolean): void {
managerView = mv;
if (!objectManager) {
pending = surfaces;
return;

View File

@ -256,9 +256,9 @@ input::placeholder { color: var(--text-muted); }
}
.decor-btn:hover::after { opacity: 1; }
.decor-menu {
/* Fixed to the viewport: JS sets `top` (25px below the header) and `max-height`
(ending 30px above the window bottom) on open, so it never overflows. */
display: none; position: fixed; right: 28px;
/* Fixed to the viewport: JS sets `top` (20px below the header/chips) and
`max-height` (ending 30px above the window bottom) on open. */
display: none; position: fixed; right: 58px;
background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius);
box-shadow: var(--shadow-md); padding: var(--size-3); z-index: 50;
width: max-content; max-width: min(420px, 92vw); overflow-y: auto;
@ -469,6 +469,7 @@ html.role-pending .manager-only { display: none !important; }
.vis-item.vis-range .vis-item-overflow { display: flex; align-items: center; height: 100%; width: 100%; overflow: hidden; }
.vis-item.vis-range .vis-item-content { padding: 0; width: 100%; }
.bar-inner {
position: relative;
height: var(--bar-h);
width: 100%;
box-sizing: border-box;
@ -483,6 +484,14 @@ html.role-pending .manager-only { display: none !important; }
overflow: hidden;
}
.vis-item.vis-range:hover .bar-inner { filter: brightness(0.92); }
/* Label wider than the bar: JS adds .bar-of, which lifts the clipping and
places the label just past the bar's right edge as dark text on the track. */
.vis-item.vis-range.bar-of .vis-item-overflow { overflow: visible; }
.vis-item.vis-range.bar-of .bar-inner { overflow: visible; }
.vis-item.vis-range.bar-of .bar-text {
position: absolute; left: 100%; top: 50%; transform: translateY(-50%);
margin-left: 5px; color: var(--text); white-space: nowrap; pointer-events: none;
}
.vis-foreground .vis-group { min-height: var(--row-h); }
.vis-labelset .vis-label { font-size: var(--label-font); display: flex; align-items: center; min-height: var(--row-h); }

View File

@ -138,6 +138,32 @@ export function createTimelineView(el: TimelineElements, appearance: Appearance)
window.addEventListener('resize', scheduleHeightFit);
scheduleHeightFit();
// When a bar's label is wider than the bar itself, move it just past the bar's
// right edge (dark text on the track) instead of clipping it inside. Re-run on
// every redraw (zoom/pan changes bar widths). Batched: reset all, measure all,
// then apply — so at most one reflow per pass.
let barTextRaf = 0;
function layoutBarText(): void {
const items = Array.from(el.timelineEl.querySelectorAll<HTMLElement>('.vis-item.vis-range'));
for (const it of items) it.classList.remove('bar-of');
const overflowing: HTMLElement[] = [];
for (const it of items) {
const inner = it.querySelector<HTMLElement>('.bar-inner');
if (inner && inner.scrollWidth > inner.clientWidth + 1) overflowing.push(it);
}
for (const it of overflowing) it.classList.add('bar-of');
}
function scheduleBarText(): void {
if (barTextRaf) return;
// Double rAF: let vis finish positioning bars before we measure widths.
barTextRaf = requestAnimationFrame(() =>
requestAnimationFrame(() => { barTextRaf = 0; layoutBarText(); }),
);
}
// 'changed' = data/redraw; 'rangechanged' = zoom/pan settled (bars resized).
timeline.on('changed', scheduleBarText);
timeline.on('rangechanged', scheduleBarText);
// ---- clicks: bar -> Planfix task; row label -> board detail panel ----
timeline.on('click', (props: any) => {
if (props.item) {
@ -367,7 +393,7 @@ export function createTimelineView(el: TimelineElements, appearance: Appearance)
group: bk.board_id,
start: bk.start_date,
end: end.toISOString().slice(0, 10),
content: `<div class="bar-inner" style="background-color:${color};">${barContent}</div>`,
content: `<div class="bar-inner" style="background-color:${color};"><span class="bar-text">${barContent}</span></div>`,
type: 'range',
tooltipHtml,
taskId: bk.task_id,
@ -382,6 +408,7 @@ export function createTimelineView(el: TimelineElements, appearance: Appearance)
if (fit) timeline.fit({ animation: false });
scheduleHeightFit();
scheduleBarText();
}
function refresh(): void {