diff --git a/client/app/map/page.tsx b/client/app/map/page.tsx index 05ab352..59cc092 100644 --- a/client/app/map/page.tsx +++ b/client/app/map/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useRef, useState, useCallback } from "react"; +import { useEffect, useRef, useState, useCallback, useMemo } from "react"; import mapboxgl from "mapbox-gl"; import "mapbox-gl/dist/mapbox-gl.css"; import MapboxDraw from "@mapbox/mapbox-gl-draw"; @@ -14,10 +14,12 @@ import { InsertModelModal } from "@/components/InsertModelModal"; import { AssetManagerPanel } from "@/components/AssetManagerPanel"; import { Prompt3DGenerator } from "@/components/Prompt3DGenerator"; import { TransformGizmo } from "@/components/TransformGizmo"; -import { SearchBar } from "@/components/SearchBar"; +import { CommandPalette } from "@/components/CommandPalette"; +import { CommandHelp } from "@/components/CommandHelp"; import { SearchResultPopup } from "@/components/SearchResultPopup"; import { MapControls } from "@/components/MapControls"; import { GitHubLogoIcon } from "@radix-ui/react-icons"; +import type { CommandContext, InsertedModel as CommandInsertedModel } from "@/lib/commands"; interface SelectedBuilding { id: string | number; @@ -137,6 +139,7 @@ export default function MapPage() { const [gizmoScreenPos, setGizmoScreenPos] = useState<{ x: number; y: number } | null>(null); const [searchQuery, setSearchQuery] = useState(""); const [isSearching, setIsSearching] = useState(false); + const [showCommandHelp, setShowCommandHelp] = useState(false); const [searchResult, setSearchResult] = useState<{ intent: { action: string; @@ -858,6 +861,51 @@ export default function MapPage() { }); }, [updateModelsSource]); + // Command palette context + const commandContext: CommandContext = useMemo(() => ({ + map: map.current, + activeTool: activeTool || "select", + setActiveTool: (tool) => handleSetActiveTool(tool === "generate" ? null : tool as "select" | "draw" | "insert" | null), + insertedModels: insertedModels as unknown as CommandInsertedModel[], + setInsertedModels: setInsertedModels as unknown as React.Dispatch>, + selectedModelId, + setSelectedModelId, + setWeather, + setLightMode, + handleUndo, + handleRedo, + handleFlyToModel, + handleDeleteModel, + setShowInsertModal: (show) => { + if (show) { + handleSetActiveTool("insert"); + } + }, + setIsGeneratorVisible: setShowPromptGenerator, + handleSearch, + setSearchQuery, + setShowLabels: (show) => { + map.current?.setConfigProperty("basemap", "showPlaceLabels", show); + }, + setShowRoads: (show) => { + map.current?.setConfigProperty("basemap", "showRoadLabels", show); + }, + setShowPOIs: (show) => { + map.current?.setConfigProperty("basemap", "showPointOfInterestLabels", show); + }, + setShowHelp: setShowCommandHelp, + }), [ + activeTool, + insertedModels, + selectedModelId, + handleUndo, + handleRedo, + handleFlyToModel, + handleDeleteModel, + handleSearch, + handleSetActiveTool, + ]); + // Keyboard shortcut for deleting selected model useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { @@ -1581,20 +1629,26 @@ export default function MapPage() { /> )} - {/* Search Bar */} + {/* Command Palette (Smart Search) */}
-
- + + {/* Command Help Modal */} + {showCommandHelp && ( + setShowCommandHelp(false)} /> + )} +
); diff --git a/client/components/CommandDropdown.tsx b/client/components/CommandDropdown.tsx new file mode 100644 index 0000000..b83a08c --- /dev/null +++ b/client/components/CommandDropdown.tsx @@ -0,0 +1,85 @@ +"use client"; + +import { useMemo } from "react"; +import { type FuzzyMatch, CATEGORY_LABELS, CATEGORY_ORDER } from "@/lib/commands"; +import { CommandItem } from "./CommandItem"; + +interface CommandDropdownProps { + matches: FuzzyMatch[]; + selectedIndex: number; + onSelect: (match: FuzzyMatch) => void; + onHover: (index: number) => void; +} + +export function CommandDropdown({ + matches, + selectedIndex, + onSelect, + onHover, +}: CommandDropdownProps) { + // Group matches by category + const grouped = useMemo(() => { + const groups = new Map(); + + matches.forEach((match, globalIndex) => { + const category = match.command.category; + const existing = groups.get(category) || []; + existing.push({ match, globalIndex }); + groups.set(category, existing); + }); + + // Sort by category order + const sortedGroups: { + category: string; + label: string; + items: { match: FuzzyMatch; globalIndex: number }[]; + }[] = []; + + for (const category of CATEGORY_ORDER) { + const items = groups.get(category); + if (items && items.length > 0) { + sortedGroups.push({ + category, + label: CATEGORY_LABELS[category], + items, + }); + } + } + + return sortedGroups; + }, [matches]); + + if (matches.length === 0) { + return null; + } + + return ( +
+ {grouped.map((group) => ( +
+ {/* Category Header */} +
+ {group.label} +
+ + {/* Commands in Category */} + {group.items.map(({ match, globalIndex }) => ( + onSelect(match)} + onHover={() => onHover(globalIndex)} + /> + ))} +
+ ))} + + {/* Hint at bottom */} +
+ Type without / for AI search + /help for all commands +
+
+ ); +} diff --git a/client/components/CommandHelp.tsx b/client/components/CommandHelp.tsx new file mode 100644 index 0000000..552ba90 --- /dev/null +++ b/client/components/CommandHelp.tsx @@ -0,0 +1,141 @@ +"use client"; + +import { useEffect } from "react"; +import { Cross2Icon } from "@radix-ui/react-icons"; +import { commands, CATEGORY_LABELS, CATEGORY_ORDER } from "@/lib/commands"; + +interface CommandHelpProps { + onClose: () => void; +} + +export function CommandHelp({ onClose }: CommandHelpProps) { + // Group commands by category + const grouped = CATEGORY_ORDER.map((category) => ({ + category, + label: CATEGORY_LABELS[category], + commands: commands.filter((c) => c.category === category), + })).filter((g) => g.commands.length > 0); + + // Close on Escape + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + onClose(); + } + }; + + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [onClose]); + + return ( +
+
e.stopPropagation()} + > + {/* Header */} +
+
+

Command Palette

+

Type / in the search bar to use commands

+
+ +
+ + {/* Content */} +
+ {grouped.map((group) => ( +
+

+ + {group.label} +

+ +
+ {group.commands.map((cmd) => { + const Icon = cmd.icon; + return ( +
+ {/* Icon */} +
+ +
+ + {/* Trigger */} + + {cmd.trigger} + + + {/* Arguments */} + + {cmd.arguments + ? cmd.arguments.map((a) => + a.required ? `<${a.name}>` : `[${a.name}]` + ).join(" ") + : "—"} + + + {/* Description */} + + {cmd.description} + + + {/* Aliases */} + {cmd.aliases.length > 0 && ( + + {cmd.aliases.join(", ")} + + )} +
+ ); + })} +
+
+ ))} + + {/* Tips Section */} +
+

+ Tips +

+
+
+ ↑ ↓ + Navigate commands +
+
+ Tab + Autocomplete command +
+
+ Enter + Execute command +
+
+ Esc + Close dropdown +
+
+ +

+ Type without / to use natural language AI search + (e.g., "take me to Tokyo" or "find the tallest building") +

+
+
+
+
+ ); +} diff --git a/client/components/CommandItem.tsx b/client/components/CommandItem.tsx new file mode 100644 index 0000000..4c8aac9 --- /dev/null +++ b/client/components/CommandItem.tsx @@ -0,0 +1,120 @@ +"use client"; + +import { type FuzzyMatch } from "@/lib/commands"; + +interface CommandItemProps { + match: FuzzyMatch; + isSelected: boolean; + onSelect: () => void; + onHover: () => void; +} + +export function CommandItem({ + match, + isSelected, + onSelect, + onHover, +}: CommandItemProps) { + const { command, highlights, matchedOn } = match; + const Icon = command.icon; + + // Render text with highlights + const renderHighlighted = (text: string, highlights: { start: number; end: number }[]) => { + if (highlights.length === 0) { + return {text}; + } + + const parts: React.ReactNode[] = []; + let lastEnd = 0; + + // Sort highlights by start position + const sorted = [...highlights].sort((a, b) => a.start - b.start); + + sorted.forEach((h, i) => { + // Add non-highlighted text before this highlight + if (h.start > lastEnd) { + parts.push( + {text.slice(lastEnd, h.start)} + ); + } + + // Add highlighted text + parts.push( + + {text.slice(h.start, h.end)} + + ); + + lastEnd = h.end; + }); + + // Add remaining text + if (lastEnd < text.length) { + parts.push({text.slice(lastEnd)}); + } + + return <>{parts}; + }; + + // Show the matched trigger/alias with highlights + const displayTrigger = matchedOn.startsWith("/") ? matchedOn : command.trigger; + + return ( + + ); +} diff --git a/client/components/CommandPalette.tsx b/client/components/CommandPalette.tsx new file mode 100644 index 0000000..46ae1f1 --- /dev/null +++ b/client/components/CommandPalette.tsx @@ -0,0 +1,242 @@ +"use client"; + +import { useState, useRef, useEffect, useCallback, useMemo } from "react"; +import { MagnifyingGlassIcon } from "@radix-ui/react-icons"; +import { + fuzzyMatchCommands, + parseCommandInput, + findCommand, + commands, + type CommandContext, + type FuzzyMatch, + type ParsedArgs, +} from "@/lib/commands"; +import { CommandDropdown } from "./CommandDropdown"; + +interface CommandPaletteProps { + value: string; + onChange: (value: string) => void; + onSearch: () => void; + context: CommandContext; + isLoading?: boolean; + placeholder?: string; +} + +export function CommandPalette({ + value, + onChange, + onSearch, + context, + isLoading = false, + placeholder = "Type / for commands, or search naturally...", +}: CommandPaletteProps) { + const [showDropdown, setShowDropdown] = useState(false); + const [selectedIndex, setSelectedIndex] = useState(0); + const [suggestions, setSuggestions] = useState([]); + + const inputRef = useRef(null); + const containerRef = useRef(null); + + // Check if input is in command mode (starts with /) + const isCommandMode = value.startsWith("/"); + + // Parse current input + const { trigger, args: argString } = useMemo( + () => parseCommandInput(value), + [value] + ); + + // Update suggestions when input changes + useEffect(() => { + if (!isCommandMode) { + setShowDropdown(false); + setSuggestions([]); + return; + } + + // Get the query part (everything after / but before the first argument) + const query = trigger; + const matches = fuzzyMatchCommands(query, commands); + + setSuggestions(matches); + setShowDropdown(matches.length > 0); + setSelectedIndex(0); + }, [isCommandMode, trigger]); + + // Execute a command + const executeCommand = useCallback( + async (match: FuzzyMatch) => { + const command = match.command; + const args: ParsedArgs = { _raw: argString }; + + try { + await command.execute(args, context); + } catch (error) { + console.error(`Failed to execute command ${command.trigger}:`, error); + } + + // Clear input and close dropdown + onChange(""); + setShowDropdown(false); + }, + [argString, context, onChange] + ); + + // Handle keyboard events + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (!showDropdown) { + // Not in command mode - handle as regular search + if (e.key === "Enter" && value.trim()) { + // Check if this is a complete command + if (isCommandMode) { + const command = findCommand(trigger, commands); + if (command) { + const args: ParsedArgs = { _raw: argString }; + command.execute(args, context); + onChange(""); + return; + } + } + onSearch(); + } + return; + } + + switch (e.key) { + case "ArrowDown": + e.preventDefault(); + setSelectedIndex((i) => Math.min(i + 1, suggestions.length - 1)); + break; + + case "ArrowUp": + e.preventDefault(); + setSelectedIndex((i) => Math.max(i - 1, 0)); + break; + + case "Tab": + e.preventDefault(); + if (suggestions[selectedIndex]) { + // Autocomplete to the command trigger + const cmd = suggestions[selectedIndex].command; + onChange(cmd.trigger + (cmd.arguments?.length ? " " : "")); + } + break; + + case "Enter": + e.preventDefault(); + if (suggestions[selectedIndex]) { + // If no arguments required, execute immediately + const cmd = suggestions[selectedIndex].command; + if (!cmd.arguments || argString.trim()) { + executeCommand(suggestions[selectedIndex]); + } else { + // Autocomplete and wait for args + onChange(cmd.trigger + " "); + } + } + break; + + case "Escape": + e.preventDefault(); + setShowDropdown(false); + break; + } + }, + [ + showDropdown, + value, + isCommandMode, + trigger, + argString, + context, + onSearch, + onChange, + suggestions, + selectedIndex, + executeCommand, + ] + ); + + // Handle click on suggestion + const handleSuggestionClick = useCallback( + (match: FuzzyMatch) => { + const cmd = match.command; + if (!cmd.arguments) { + // No arguments - execute immediately + executeCommand(match); + } else { + // Has arguments - autocomplete and focus + onChange(cmd.trigger + " "); + inputRef.current?.focus(); + } + }, + [executeCommand, onChange] + ); + + // Close dropdown when clicking outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + containerRef.current && + !containerRef.current.contains(event.target as Node) + ) { + setShowDropdown(false); + } + }; + + if (showDropdown) { + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + } + }, [showDropdown]); + + return ( +
+ {/* Command Suggestions Dropdown (appears above input) */} + {showDropdown && suggestions.length > 0 && ( + + )} + + {/* Search Input */} +
+
+ +
+ + onChange(e.target.value)} + onKeyDown={handleKeyDown} + placeholder={placeholder} + className="w-full pl-12 pr-3 py-2 bg-transparent text-white text-sm placeholder:text-white/40 outline-none disabled:opacity-50 disabled:cursor-not-allowed" + disabled={isLoading} + autoComplete="off" + spellCheck={false} + /> + + {isLoading && ( +
+
+
+ )} + + {/* Command mode indicator */} + {isCommandMode && !isLoading && ( +
+ + ↑↓ navigate • Tab complete • Enter select + +
+ )} +
+
+ ); +} diff --git a/client/components/Prompt3DGenerator.tsx b/client/components/Prompt3DGenerator.tsx index fd77f94..299d20b 100644 --- a/client/components/Prompt3DGenerator.tsx +++ b/client/components/Prompt3DGenerator.tsx @@ -153,7 +153,6 @@ export function Prompt3DGenerator({ isVisible, onClose, onRequestExpand, onPlace const response = await fetch(`${API_BASE}/generate-preview`, { method: "POST", headers: { "Content-Type": "application/json" }, - credentials: 'include', // Send session cookie body: JSON.stringify({ prompt, style, @@ -206,9 +205,7 @@ export function Prompt3DGenerator({ isVisible, onClose, onRequestExpand, onPlace pollIntervalRef.current = setInterval(async () => { try { - const statusRes = await fetch(`${API_BASE}/3d-job/${jobId}`, { - credentials: 'include' // Important: send session cookie - }); + const statusRes = await fetch(`${API_BASE}/3d-job/${jobId}`); if (!statusRes.ok) return; const status: ThreeDJobResult = await statusRes.json(); diff --git a/client/lib/commands/fuzzy.ts b/client/lib/commands/fuzzy.ts new file mode 100644 index 0000000..05a2f48 --- /dev/null +++ b/client/lib/commands/fuzzy.ts @@ -0,0 +1,213 @@ +import type { Command, FuzzyMatch } from "./types"; + +/** + * Calculate fuzzy match score between query and candidate string. + * Returns score (0 = no match, higher = better) and highlight positions. + */ +function calculateScore( + query: string, + candidate: string +): { score: number; highlights: { start: number; end: number }[] } { + if (!query) { + return { score: 0, highlights: [] }; + } + + const normalizedQuery = query.toLowerCase(); + const normalizedCandidate = candidate.toLowerCase(); + + // Exact match (highest score) + if (normalizedCandidate === normalizedQuery) { + return { + score: 1000, + highlights: [{ start: 0, end: candidate.length }], + }; + } + + // Prefix match (very high score) + if (normalizedCandidate.startsWith(normalizedQuery)) { + return { + score: 500 + (query.length / candidate.length) * 100, + highlights: [{ start: 0, end: query.length }], + }; + } + + // Contains match + const containsIndex = normalizedCandidate.indexOf(normalizedQuery); + if (containsIndex !== -1) { + return { + score: 200 + (query.length / candidate.length) * 50, + highlights: [{ start: containsIndex, end: containsIndex + query.length }], + }; + } + + // Fuzzy character-by-character match + let queryIndex = 0; + let score = 0; + const highlights: { start: number; end: number }[] = []; + let currentHighlight: { start: number; end: number } | null = null; + + for (let i = 0; i < normalizedCandidate.length && queryIndex < normalizedQuery.length; i++) { + if (normalizedCandidate[i] === normalizedQuery[queryIndex]) { + // Consecutive matches score higher + if (currentHighlight && currentHighlight.end === i) { + currentHighlight.end = i + 1; + score += 20; // Bonus for consecutive + } else { + if (currentHighlight) { + highlights.push(currentHighlight); + } + currentHighlight = { start: i, end: i + 1 }; + score += 10; + } + + // Bonus for matching at word boundaries + if (i === 0 || normalizedCandidate[i - 1] === " " || normalizedCandidate[i - 1] === "/") { + score += 15; + } + + queryIndex++; + } + } + + if (currentHighlight) { + highlights.push(currentHighlight); + } + + // Only return score if all query characters were matched + if (queryIndex === normalizedQuery.length) { + return { score, highlights }; + } + + return { score: 0, highlights: [] }; +} + +/** + * Find matching commands for a query string. + * Query should already have the "/" prefix removed. + */ +export function fuzzyMatchCommands(query: string, commands: Command[]): FuzzyMatch[] { + const normalizedQuery = query.toLowerCase().replace(/^\//, ""); + + if (!normalizedQuery) { + // Return all commands when query is empty (just "/") + return commands.map((command) => ({ + command, + score: 0, + matchedOn: command.trigger, + highlights: [], + })); + } + + const matches: FuzzyMatch[] = []; + + for (const command of commands) { + let bestScore = 0; + let bestMatchedOn = command.trigger; + let bestHighlights: { start: number; end: number }[] = []; + + // Try matching against trigger (without "/" prefix) + const triggerWithoutSlash = command.trigger.replace(/^\//, ""); + const triggerResult = calculateScore(normalizedQuery, triggerWithoutSlash); + if (triggerResult.score > bestScore) { + bestScore = triggerResult.score; + bestMatchedOn = command.trigger; + // Offset highlights by 1 to account for "/" prefix in display + bestHighlights = triggerResult.highlights.map((h) => ({ + start: h.start + 1, + end: h.end + 1, + })); + } + + // Try matching against aliases + for (const alias of command.aliases) { + const aliasWithoutSlash = alias.replace(/^\//, ""); + const aliasResult = calculateScore(normalizedQuery, aliasWithoutSlash); + if (aliasResult.score > bestScore) { + bestScore = aliasResult.score; + bestMatchedOn = alias; + bestHighlights = aliasResult.highlights.map((h) => ({ + start: h.start + 1, + end: h.end + 1, + })); + } + } + + // Try matching against name + const nameResult = calculateScore(normalizedQuery, command.name); + if (nameResult.score > bestScore) { + bestScore = nameResult.score; + bestMatchedOn = command.name; + bestHighlights = nameResult.highlights; + } + + if (bestScore > 0) { + matches.push({ + command, + score: bestScore, + matchedOn: bestMatchedOn, + highlights: bestHighlights, + }); + } + } + + // Sort by score (descending), then by trigger alphabetically + return matches.sort((a, b) => { + if (b.score !== a.score) { + return b.score - a.score; + } + return a.command.trigger.localeCompare(b.command.trigger); + }); +} + +/** + * Parse a command string into trigger and arguments. + * e.g., "/teleport toronto canada" -> { trigger: "/teleport", args: "toronto canada" } + */ +export function parseCommandInput(input: string): { trigger: string; args: string } { + const trimmed = input.trim(); + const spaceIndex = trimmed.indexOf(" "); + + if (spaceIndex === -1) { + return { trigger: trimmed, args: "" }; + } + + return { + trigger: trimmed.slice(0, spaceIndex), + args: trimmed.slice(spaceIndex + 1).trim(), + }; +} + +/** + * Find the best matching command for a trigger string. + */ +export function findCommand(trigger: string, commands: Command[]): Command | null { + const normalizedTrigger = trigger.toLowerCase(); + + for (const command of commands) { + if (command.trigger.toLowerCase() === normalizedTrigger) { + return command; + } + for (const alias of command.aliases) { + if (alias.toLowerCase() === normalizedTrigger) { + return command; + } + } + } + + return null; +} + +/** + * Group commands by category. + */ +export function groupCommandsByCategory(commands: Command[]): Map { + const groups = new Map(); + + for (const command of commands) { + const existing = groups.get(command.category) || []; + existing.push(command); + groups.set(command.category, existing); + } + + return groups; +} diff --git a/client/lib/commands/index.ts b/client/lib/commands/index.ts new file mode 100644 index 0000000..593c509 --- /dev/null +++ b/client/lib/commands/index.ts @@ -0,0 +1,13 @@ +// Types +export * from "./types"; + +// Command registry +export { commands, getAllCommands } from "./registry"; + +// Fuzzy matching utilities +export { + fuzzyMatchCommands, + parseCommandInput, + findCommand, + groupCommandsByCategory, +} from "./fuzzy"; diff --git a/client/lib/commands/registry.ts b/client/lib/commands/registry.ts new file mode 100644 index 0000000..bb45443 --- /dev/null +++ b/client/lib/commands/registry.ts @@ -0,0 +1,554 @@ +import { + RocketIcon, + HomeIcon, + MagnifyingGlassIcon, + ZoomInIcon, + MixerVerticalIcon, + ReloadIcon, + DashboardIcon, + CubeIcon, + SunIcon, + MoonIcon, + OpacityIcon, + CursorArrowIcon, + Pencil1Icon, + PlusCircledIcon, + MagicWandIcon, + ListBulletIcon, + TrashIcon, + ResetIcon, + CounterClockwiseClockIcon, + ClockIcon, + EyeOpenIcon, + QuestionMarkCircledIcon, + TargetIcon, +} from "@radix-ui/react-icons"; +import type { Command, ParsedArgs, CommandContext } from "./types"; + +// Helper to fly the map to coordinates +async function flyToLocation( + context: CommandContext, + query: string +): Promise { + if (!context.map || !query) return; + + // Use Mapbox Geocoding API + const token = process.env.NEXT_PUBLIC_MAPBOX_TOKEN; + if (!token) return; + + try { + const response = await fetch( + `https://api.mapbox.com/geocoding/v5/mapbox.places/${encodeURIComponent( + query + )}.json?access_token=${token}&limit=1` + ); + const data = await response.json(); + + if (data.features && data.features.length > 0) { + const [lng, lat] = data.features[0].center; + context.map.flyTo({ + center: [lng, lat], + zoom: 15, + pitch: 60, + duration: 2000, + }); + } + } catch (error) { + console.error("Failed to geocode location:", error); + } +} + +// All command definitions +export const commands: Command[] = [ + // ==================== NAVIGATION ==================== + { + id: "teleport", + name: "Teleport", + trigger: "/teleport", + aliases: ["/goto", "/fly", "/tp"], + description: "Fly to a location", + category: "navigation", + icon: RocketIcon, + arguments: [ + { name: "location", type: "location", required: true, placeholder: "city, address, or landmark" }, + ], + execute: async (args, context) => { + const location = args._raw; + if (location) { + await flyToLocation(context, location); + } + }, + }, + { + id: "home", + name: "Home", + trigger: "/home", + aliases: ["/reset"], + description: "Return to initial globe view", + category: "navigation", + icon: HomeIcon, + execute: (_, context) => { + context.map?.flyTo({ + center: [0, 20], + zoom: 1.5, + pitch: 0, + bearing: 0, + duration: 2000, + }); + }, + }, + + // ==================== CAMERA ==================== + { + id: "zoom", + name: "Zoom", + trigger: "/zoom", + aliases: ["/z"], + description: "Set zoom level or zoom in/out", + category: "camera", + icon: ZoomInIcon, + arguments: [ + { name: "level", type: "string", required: true, placeholder: "in, out, or 1-22" }, + ], + execute: (args, context) => { + const value = args._raw.toLowerCase(); + if (!context.map) return; + + if (value === "in") { + context.map.zoomIn({ duration: 300 }); + } else if (value === "out") { + context.map.zoomOut({ duration: 300 }); + } else { + const level = parseFloat(value); + if (!isNaN(level) && level >= 0 && level <= 22) { + context.map.flyTo({ zoom: level, duration: 500 }); + } + } + }, + }, + { + id: "pitch", + name: "Pitch", + trigger: "/pitch", + aliases: ["/tilt"], + description: "Set camera pitch (0-85 degrees)", + category: "camera", + icon: MixerVerticalIcon, + arguments: [ + { name: "degrees", type: "number", required: true, placeholder: "0-85" }, + ], + execute: (args, context) => { + const degrees = parseFloat(args._raw); + if (!isNaN(degrees) && degrees >= 0 && degrees <= 85) { + context.map?.flyTo({ pitch: degrees, duration: 500 }); + } + }, + }, + { + id: "bearing", + name: "Bearing", + trigger: "/bearing", + aliases: ["/rotate"], + description: "Set map rotation (degrees)", + category: "camera", + icon: ReloadIcon, + arguments: [ + { name: "degrees", type: "number", required: true, placeholder: "0-360" }, + ], + execute: (args, context) => { + const degrees = parseFloat(args._raw); + if (!isNaN(degrees)) { + context.map?.flyTo({ bearing: degrees % 360, duration: 500 }); + } + }, + }, + { + id: "2d", + name: "2D View", + trigger: "/2d", + aliases: ["/flat", "/topdown"], + description: "Switch to 2D top-down view", + category: "camera", + icon: DashboardIcon, + execute: (_, context) => { + context.map?.flyTo({ pitch: 0, duration: 500 }); + }, + }, + { + id: "3d", + name: "3D View", + trigger: "/3d", + aliases: ["/perspective"], + description: "Switch to 3D perspective view", + category: "camera", + icon: CubeIcon, + execute: (_, context) => { + context.map?.flyTo({ pitch: 60, duration: 500 }); + }, + }, + { + id: "north", + name: "North", + trigger: "/north", + aliases: ["/compass"], + description: "Reset bearing to north", + category: "camera", + icon: TargetIcon, + execute: (_, context) => { + context.map?.flyTo({ bearing: 0, duration: 500 }); + }, + }, + + // ==================== WEATHER ==================== + { + id: "weather", + name: "Weather", + trigger: "/weather", + aliases: ["/w"], + description: "Set weather (clear, rain, snow)", + category: "weather", + icon: OpacityIcon, + arguments: [ + { name: "type", type: "select", required: true, options: ["clear", "rain", "snow"] }, + ], + execute: (args, context) => { + const type = args._raw.toLowerCase(); + if (type === "clear" || type === "rain" || type === "snow") { + context.setWeather(type); + } + }, + }, + { + id: "rain", + name: "Rain", + trigger: "/rain", + aliases: ["/rainy"], + description: "Enable rain weather", + category: "weather", + icon: OpacityIcon, + execute: (_, context) => { + context.setWeather("rain"); + }, + }, + { + id: "snow", + name: "Snow", + trigger: "/snow", + aliases: ["/snowy"], + description: "Enable snow weather", + category: "weather", + icon: OpacityIcon, + execute: (_, context) => { + context.setWeather("snow"); + }, + }, + { + id: "clear", + name: "Clear", + trigger: "/clear", + aliases: ["/sunny"], + description: "Clear weather effects", + category: "weather", + icon: SunIcon, + execute: (_, context) => { + context.setWeather("clear"); + }, + }, + + // ==================== TIME ==================== + { + id: "time", + name: "Time", + trigger: "/time", + aliases: ["/t"], + description: "Set time of day (day, night)", + category: "time", + icon: ClockIcon, + arguments: [ + { name: "mode", type: "select", required: true, options: ["day", "night"] }, + ], + execute: (args, context) => { + const mode = args._raw.toLowerCase(); + if (mode === "day" || mode === "night") { + context.setLightMode(mode); + } + }, + }, + { + id: "day", + name: "Day", + trigger: "/day", + aliases: ["/morning", "/daytime"], + description: "Switch to daytime", + category: "time", + icon: SunIcon, + execute: (_, context) => { + context.setLightMode("day"); + }, + }, + { + id: "night", + name: "Night", + trigger: "/night", + aliases: ["/evening", "/nighttime"], + description: "Switch to nighttime", + category: "time", + icon: MoonIcon, + execute: (_, context) => { + context.setLightMode("night"); + }, + }, + + // ==================== TOOLS ==================== + { + id: "select", + name: "Select", + trigger: "/select", + aliases: ["/s", "/sel"], + description: "Activate select tool", + category: "tools", + icon: CursorArrowIcon, + execute: (_, context) => { + context.setActiveTool("select"); + }, + }, + { + id: "delete", + name: "Delete", + trigger: "/delete", + aliases: ["/d", "/draw", "/erase"], + description: "Activate delete/draw tool", + category: "tools", + icon: Pencil1Icon, + execute: (_, context) => { + context.setActiveTool("draw"); + }, + }, + { + id: "insert", + name: "Insert", + trigger: "/insert", + aliases: ["/i", "/add", "/model"], + description: "Open insert model modal", + category: "tools", + icon: PlusCircledIcon, + execute: (_, context) => { + context.setActiveTool("insert"); + context.setShowInsertModal(true); + }, + }, + { + id: "generate", + name: "Generate", + trigger: "/generate", + aliases: ["/g", "/gen", "/ai"], + description: "Open AI 3D model generator", + category: "tools", + icon: MagicWandIcon, + execute: (_, context) => { + context.setActiveTool("generate"); + context.setIsGeneratorVisible(true); + }, + }, + + // ==================== MODELS ==================== + { + id: "models", + name: "Models", + trigger: "/models", + aliases: ["/assets", "/list"], + description: "List all placed models", + category: "models", + icon: ListBulletIcon, + execute: (_, context) => { + const models = context.insertedModels; + if (models.length === 0) { + console.log("No models placed"); + } else { + console.log(`${models.length} model(s) placed:`); + models.forEach((m, i) => { + console.log(` ${i + 1}. ${m.name || `Model ${m.id.slice(0, 8)}`}`); + }); + } + // TODO: Could show a panel with models list + }, + }, + { + id: "flyto", + name: "Fly To Model", + trigger: "/flyto", + aliases: ["/focus"], + description: "Fly to a placed model", + category: "models", + icon: TargetIcon, + arguments: [ + { name: "model", type: "model", required: true, placeholder: "model name" }, + ], + execute: (args, context) => { + const query = args._raw.toLowerCase(); + const model = context.insertedModels.find( + (m) => + (m.name || `Model ${m.id.slice(0, 8)}`).toLowerCase().includes(query) || + m.id.toLowerCase().includes(query) + ); + if (model) { + context.handleFlyToModel(model.position); + context.setSelectedModelId(model.id); + } + }, + }, + { + id: "deletemodel", + name: "Delete Model", + trigger: "/deletemodel", + aliases: ["/rm"], + description: "Delete a placed model", + category: "models", + icon: TrashIcon, + arguments: [ + { name: "model", type: "model", required: true, placeholder: "model name" }, + ], + execute: (args, context) => { + const query = args._raw.toLowerCase(); + const model = context.insertedModels.find( + (m) => + (m.name || `Model ${m.id.slice(0, 8)}`).toLowerCase().includes(query) || + m.id.toLowerCase().includes(query) + ); + if (model) { + context.handleDeleteModel(model.id); + } + }, + }, + + // ==================== BUILDINGS ==================== + { + id: "find", + name: "Find", + trigger: "/find", + aliases: ["/search"], + description: "Find buildings (uses AI search)", + category: "buildings", + icon: MagnifyingGlassIcon, + arguments: [ + { name: "query", type: "string", required: true, placeholder: "tallest, biggest, etc." }, + ], + execute: (args, context) => { + // Delegate to natural language search + context.setSearchQuery(args._raw); + context.handleSearch(); + }, + }, + { + id: "demolish", + name: "Demolish", + trigger: "/demolish", + aliases: [], + description: "Delete a building at location", + category: "buildings", + icon: TrashIcon, + arguments: [ + { name: "location", type: "location", required: true, placeholder: "building name or location" }, + ], + execute: (args, context) => { + // Delegate to natural language search with delete intent + context.setSearchQuery(`delete ${args._raw}`); + context.handleSearch(); + }, + }, + + // ==================== HISTORY ==================== + { + id: "undo", + name: "Undo", + trigger: "/undo", + aliases: ["/u"], + description: "Undo last action", + category: "history", + icon: CounterClockwiseClockIcon, + execute: (_, context) => { + context.handleUndo(); + }, + }, + { + id: "redo", + name: "Redo", + trigger: "/redo", + aliases: ["/r"], + description: "Redo last undone action", + category: "history", + icon: ResetIcon, + execute: (_, context) => { + context.handleRedo(); + }, + }, + + // ==================== MAP STYLE ==================== + { + id: "labels", + name: "Labels", + trigger: "/labels", + aliases: ["/showlabels"], + description: "Toggle place labels (on/off)", + category: "map", + icon: EyeOpenIcon, + arguments: [ + { name: "state", type: "select", required: true, options: ["on", "off"] }, + ], + execute: (args, context) => { + const state = args._raw.toLowerCase(); + context.setShowLabels(state === "on"); + }, + }, + { + id: "roads", + name: "Roads", + trigger: "/roads", + aliases: ["/showroads"], + description: "Toggle road labels (on/off)", + category: "map", + icon: EyeOpenIcon, + arguments: [ + { name: "state", type: "select", required: true, options: ["on", "off"] }, + ], + execute: (args, context) => { + const state = args._raw.toLowerCase(); + context.setShowRoads(state === "on"); + }, + }, + { + id: "pois", + name: "POIs", + trigger: "/pois", + aliases: ["/showpois"], + description: "Toggle POI labels (on/off)", + category: "map", + icon: EyeOpenIcon, + arguments: [ + { name: "state", type: "select", required: true, options: ["on", "off"] }, + ], + execute: (args, context) => { + const state = args._raw.toLowerCase(); + context.setShowPOIs(state === "on"); + }, + }, + + // ==================== HELP ==================== + { + id: "help", + name: "Help", + trigger: "/help", + aliases: ["/h", "/?", "/commands"], + description: "Show all available commands", + category: "help", + icon: QuestionMarkCircledIcon, + execute: (_, context) => { + context.setShowHelp(true); + }, + }, +]; + +// Export helper to get all commands +export function getAllCommands(): Command[] { + return commands; +} diff --git a/client/lib/commands/types.ts b/client/lib/commands/types.ts new file mode 100644 index 0000000..bacf9db --- /dev/null +++ b/client/lib/commands/types.ts @@ -0,0 +1,147 @@ +import type { IconProps } from "@radix-ui/react-icons/dist/types"; + +// Tool types from the map page +export type ToolType = "select" | "draw" | "insert" | "generate"; + +// Weather types +export type WeatherType = "clear" | "rain" | "snow"; + +// Light mode types +export type LightMode = "day" | "night"; + +// Command categories for grouping in UI +export type CommandCategory = + | "navigation" + | "camera" + | "weather" + | "time" + | "tools" + | "models" + | "buildings" + | "history" + | "map" + | "help"; + +// Argument types +export type ArgumentType = "string" | "number" | "select" | "location" | "model"; + +// Command argument definition +export interface CommandArgument { + name: string; + type: ArgumentType; + required: boolean; + placeholder?: string; + options?: string[]; // For 'select' type +} + +// Parsed arguments from user input +export interface ParsedArgs { + [key: string]: string | number | undefined; + _raw: string; // The full raw argument string +} + +// Inserted model type (matches map page) +export interface InsertedModel { + id: string; + name?: string; + position: [number, number]; // [lng, lat] + height: number; + scale: number; + rotationX: number; + rotationY: number; + rotationZ: number; + modelUrl: string; +} + +// Context passed to command execute functions +export interface CommandContext { + // Map instance + map: mapboxgl.Map | null; + + // Tool state + activeTool: ToolType; + setActiveTool: (tool: ToolType) => void; + + // Model state + insertedModels: InsertedModel[]; + setInsertedModels: React.Dispatch>; + selectedModelId: string | null; + setSelectedModelId: (id: string | null) => void; + + // Weather/Time + setWeather: (weather: WeatherType) => void; + setLightMode: (mode: LightMode) => void; + + // History + handleUndo: () => void; + handleRedo: () => void; + + // Model operations + handleFlyToModel: (position: [number, number]) => void; + handleDeleteModel: (id: string) => void; + + // UI state + setShowInsertModal: (show: boolean) => void; + setIsGeneratorVisible: (visible: boolean) => void; + + // Search (for natural language fallback and teleport) + handleSearch: () => void; + setSearchQuery: (query: string) => void; + + // Map style + setShowLabels: (show: boolean) => void; + setShowRoads: (show: boolean) => void; + setShowPOIs: (show: boolean) => void; + + // Help modal + setShowHelp: (show: boolean) => void; +} + +// Command definition +export interface Command { + id: string; + name: string; + trigger: string; + aliases: string[]; + description: string; + category: CommandCategory; + icon: React.ComponentType; + arguments?: CommandArgument[]; + execute: (args: ParsedArgs, context: CommandContext) => void | Promise; +} + +// Fuzzy match result +export interface FuzzyMatch { + command: Command; + score: number; + matchedOn: string; // Which string was matched (trigger, alias, or name) + highlights: { start: number; end: number }[]; // Character positions to highlight +} + +// Category display info +export const CATEGORY_LABELS: Record = { + navigation: "Navigation", + camera: "Camera", + weather: "Weather", + time: "Time", + tools: "Tools", + models: "Models", + buildings: "Buildings", + history: "History", + map: "Map Style", + help: "Help", +}; + +// Category order for display +export const CATEGORY_ORDER: CommandCategory[] = [ + "navigation", + "tools", + "camera", + "weather", + "time", + "models", + "buildings", + "history", + "map", + "help", +];