/* 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 (