/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ /** * AnimationSettingsPopover — compact dropdown from the Gantt toolbar that * controls the 4D animation behaviour. * * Two conceptual layers: * • **Timing** — schedule-driven visibility: hide upcoming products, * remove demolished ones. Always available. * • **Colour overlays** (phased only, opt-in) — task-type palette with * a fully editable colour picker on each swatch. * * Layout rationale: in phased mode the palette editor is front and centre * (right after the style tiles) so users can actually find it — previous * iterations buried it at the bottom of the popover and the common * complaint was "I don't see how I can change colours". Each swatch is a * 20 px clickable preview bound to a native ``. */ import { useCallback } from 'react'; import { Sparkles, RotateCcw, Paintbrush, Palette, Eye } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger, DropdownMenuSeparator, } from '@/components/ui/dropdown-menu'; import { Switch } from '@/components/ui/switch'; import { Label } from '@/components/ui/label'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { cn } from '@/lib/utils'; import { useViewerStore } from '@/store'; import { DEFAULT_PALETTE, type AnimationSettings, type TaskPaletteKey, type RGBA, } from './schedule-animator'; interface AnimationSettingsPopoverProps { animationEnabled: boolean; onToggleAnimation: () => void; } /** Palette entries surfaced in the customizer — every IfcTaskTypeEnum * value the animator uses. Ordered by expected real-world frequency. */ const PALETTE_LEGEND: { key: TaskPaletteKey; label: string }[] = [ { key: 'CONSTRUCTION', label: 'Construction' }, { key: 'INSTALLATION', label: 'Installation' }, { key: 'RENOVATION', label: 'Renovation' }, { key: 'MAINTENANCE', label: 'Maintenance' }, { key: 'LOGISTIC', label: 'Logistic' }, { key: 'OPERATION', label: 'Operation' }, { key: 'MOVE', label: 'Move' }, { key: 'ATTENDANCE', label: 'Attendance' }, { key: 'DEMOLITION', label: 'Demolition' }, { key: 'DISMANTLE', label: 'Dismantle' }, { key: 'REMOVAL', label: 'Removal' }, { key: 'DISPOSAL', label: 'Disposal' }, { key: 'USERDEFINED', label: 'User-defined' }, { key: 'NOTDEFINED', label: 'Not defined' }, ]; function rgbaToCss(rgba: RGBA): string { const r = Math.round(rgba[0] * 255); const g = Math.round(rgba[1] * 255); const b = Math.round(rgba[2] * 255); return `rgba(${r},${g},${b},${rgba[3]})`; } function rgbaToHex(rgba: RGBA): string { const toHex = (v: number) => Math.round(Math.min(1, Math.max(0, v)) * 255).toString(16).padStart(2, '0'); return `#${toHex(rgba[0])}${toHex(rgba[1])}${toHex(rgba[2])}`; } /** Parse `#RRGGBB` into [r,g,b] floats 0-1 (alpha left to caller). */ function hexToRgb(hex: string): [number, number, number] | null { const m = hex.match(/^#?([0-9a-f]{6})$/i); if (!m) return null; const v = parseInt(m[1], 16); return [((v >> 16) & 0xff) / 255, ((v >> 8) & 0xff) / 255, (v & 0xff) / 255]; } /** Colour-equal within 1/255 — used to spot user-customised entries. */ function rgbEquals(a: RGBA, b: RGBA): boolean { const eps = 1 / 512; return Math.abs(a[0] - b[0]) < eps && Math.abs(a[1] - b[1]) < eps && Math.abs(a[2] - b[2]) < eps; } export function AnimationSettingsPopover({ animationEnabled, onToggleAnimation, }: AnimationSettingsPopoverProps) { const settings = useViewerStore(s => s.animationSettings); const patch = useViewerStore(s => s.patchAnimationSettings); const reset = useViewerStore(s => s.resetAnimationSettings); // Minimal / Phased tiles are presets over the underlying colour // flags, not a separate mode flag. "Phased" turns on task-type // coloring at a sensible default intensity; "Minimal" turns every // colour overlay off. Users can still toggle individual flags // inside the Phased panel after picking either preset. const applyMinimalPreset = useCallback(() => patch({ colorizeByTaskType: false, showPreparationGhost: false, showCompletedTint: false, paletteIntensity: 0, }), [patch]); const applyPhasedPreset = useCallback(() => patch({ colorizeByTaskType: true, paletteIntensity: 0.6, // Leave ghost / completed off by default — power-user toggles inside. }), [patch]); const setPaletteColor = useCallback((key: TaskPaletteKey, hex: string) => { const rgb = hexToRgb(hex); if (!rgb) return; const prev = settings.palette[key] ?? DEFAULT_PALETTE[key]; // Preserve the existing alpha — the native picker is opaque so we only // update RGB. Keeps the PREPARATION ghost at its baked low alpha even // when users edit its hue. const next: RGBA = [rgb[0], rgb[1], rgb[2], prev[3]]; patch({ palette: { ...settings.palette, [key]: next } }); }, [patch, settings.palette]); const resetPaletteEntry = useCallback((key: TaskPaletteKey) => { patch({ palette: { ...settings.palette, [key]: DEFAULT_PALETTE[key] } }); }, [patch, settings.palette]); // Derive the tile state from the underlying flags — "phased" means // at least one colour overlay is on. No separate `style` bit. const phased = settings.colorizeByTaskType || settings.showPreparationGhost || settings.showCompletedTint; const palette = settings.palette; const prepColor = palette.PREPARATION ?? DEFAULT_PALETTE.PREPARATION; const prepIsDefault = rgbEquals(prepColor, DEFAULT_PALETTE.PREPARATION); const completedColor = palette.COMPLETED ?? DEFAULT_PALETTE.COMPLETED; const completedIsDefault = rgbEquals(completedColor, DEFAULT_PALETTE.COMPLETED); return ( 4D animation settings {/* ── Master toggle ────────────────────────────────────────── */}
4D animation Drives viewport from the Gantt clock.
{/* ── Style tiles — two ways to visualize the schedule ─────── */}
} label="Minimal" description="Visibility only — no colour" active={!phased} onSelect={() => applyMinimalPreset()} /> } label="Phased" description="Task-type colour overlays" active={phased} onSelect={() => applyPhasedPreset()} />
{/* ── Phased: palette editor FIRST so it's impossible to miss ── */} {phased && ( <>
Click any swatch to change its colour. Hover a modified entry to reset just that one.
{PALETTE_LEGEND.map(entry => { const current = palette[entry.key] ?? DEFAULT_PALETTE[entry.key]; return ( ); })}
)} {/* ── Minimal: clear CTA explaining what phased adds ───────── */} {!phased && ( <> )} {/* ── Timing-layer toggles (always visible) ────────────────── */}
patch({ hideBeforePreparation: v })} /> patch({ hideUntaskedProducts: v })} /> patch({ animateDemolition: v })} />
{phased && ( <> {/* ── Colour-layer toggles ─────────────────────────────── */}
patch({ colorizeByTaskType: v })} /> patch({ showPreparationGhost: v })} /> {settings.showPreparationGhost && (
Ghost colour Low-alpha dim applied to upcoming products.
)} patch({ showCompletedTint: v })} /> {settings.showCompletedTint && (
Completed colour Low-alpha tint applied after a task finishes.
)}
{/* ── Sliders ──────────────────────────────────────────── */}
{settings.preparationDays}d
patch({ preparationDays: Number(e.target.value) })} className="w-full accent-primary" />
{Math.round(settings.paletteIntensity * 100)}%
patch({ paletteIntensity: Number(e.target.value) / 100 })} className="w-full accent-primary" /> 0% = no colour (equivalent to Minimal); 100% = solid paint.
)}
); } interface StyleTileProps { icon: React.ReactNode; label: string; description: string; active: boolean; onSelect: () => void; } function StyleTile({ icon, label, description, active, onSelect }: StyleTileProps) { return ( ); } interface ToggleRowProps { label: string; description: string; checked: boolean; onChange: (next: boolean) => void; } function ToggleRow({ label, description, checked, onChange }: ToggleRowProps) { return ( ); } interface PaletteRowProps { label: string; colorKey: TaskPaletteKey; rgba: RGBA; onChange: (key: TaskPaletteKey, hex: string) => void; onResetEntry: (key: TaskPaletteKey) => void; isDefault: boolean; } /** * Full-width palette row — 20 px clickable swatch + friendly label + hex * code + per-entry reset on hover when modified. Larger than the old 14 px * swatches so the interactive affordance actually reads as a button. */ function PaletteRow({ label, colorKey, rgba, onChange, onResetEntry, isDefault }: PaletteRowProps) { return (
{label} {rgbaToHex(rgba).toUpperCase()} {!isDefault && • modified}
{!isDefault && ( Reset to default )}
); } interface PaletteSwatchProps { colorKey: TaskPaletteKey; rgba: RGBA; onChange: (key: TaskPaletteKey, hex: string) => void; /** Kept for parent-side rendering; not used inside the swatch. */ isDefault?: boolean; } /** * 20 × 20 px swatch that doubles as a ``. A subtle * checker pattern behind the colour communicates alpha (useful for the * PREPARATION ghost which has baked low alpha), and a ring on * hover/focus confirms it's interactive. */ function PaletteSwatch({ colorKey, rgba, onChange }: PaletteSwatchProps) { return ( ); }