@pascal-app/editor 1.1.0 → 1.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +9 -15
- package/src/components/editor/action-menu-button.tsx +1 -1
- package/src/components/editor/alignment-3d-guide-layer.tsx +1 -1
- package/src/components/editor/custom-camera-controls.tsx +11 -11
- package/src/components/editor/editor-layout-v2.tsx +1 -1
- package/src/components/editor/elevation-3d-guide-layer.tsx +1 -1
- package/src/components/editor/first-person-controls.tsx +20 -1981
- package/src/components/editor/floating-action-menu.tsx +1 -1
- package/src/components/editor/floating-building-action-menu.tsx +1 -1
- package/src/components/editor/floor-region-controls.tsx +6 -2
- package/src/components/editor/floorplan-navigation-presentation.test.ts +0 -40
- package/src/components/editor/floorplan-navigation-presentation.ts +0 -66
- package/src/components/editor/floorplan-panel.tsx +26 -22
- package/src/components/editor/floorplan-wall-typing-keys.ts +1 -1
- package/src/components/editor/follow-pascal.test.tsx +1 -2
- package/src/components/editor/follow-pascal.tsx +1 -1
- package/src/components/editor/footprint-height-handle.tsx +1 -1
- package/src/components/editor/group-actions.ts +1 -2
- package/src/components/editor/group-floating-action-menu.tsx +1 -1
- package/src/components/editor/group-move-3d.ts +1 -2
- package/src/components/editor/group-rotate-handle.tsx +1 -2
- package/src/components/editor/handles/use-handle-drag.ts +1 -2
- package/src/components/editor/index.tsx +24 -14
- package/src/components/editor/inspector-extension-actions.tsx +1 -1
- package/src/components/editor/node-arrow-handles.tsx +1 -1
- package/src/components/editor/open-wall-ends-3d-layer.tsx +1 -1
- package/src/components/editor/opening-guides-3d-layer.tsx +1 -1
- package/src/components/editor/room-controls.tsx +7 -4
- package/src/components/editor/room-handles-2d.tsx +2 -3
- package/src/components/editor/room-handles.tsx +1 -2
- package/src/components/editor/room-highlight.tsx +6 -105
- package/src/components/editor/room-labels.test.ts +8 -822
- package/src/components/editor/room-labels.tsx +40 -1671
- package/src/components/editor/room-webgpu-materials.test.ts +1 -1
- package/src/components/editor/selection-manager.tsx +5 -3
- package/src/components/editor/site-edge-labels.tsx +1 -1
- package/src/components/editor/snapshot-capture-overlay.tsx +2 -5
- package/src/components/editor/use-floorplan-background-placement.ts +2 -1
- package/src/components/editor/views/plugins-view.tsx +1 -1
- package/src/components/editor/views/view-bar.tsx +9 -9
- package/src/components/editor/wall-measurement-label.tsx +1 -1
- package/src/components/editor/wall-move-side-handles.tsx +1 -1
- package/src/components/editor/wall-pull-grip.tsx +2 -2
- package/src/components/editor-2d/floorplan-group-move.tsx +2 -3
- package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +1 -1
- package/src/components/editor-2d/floorplan-open-wall-ends-layer.tsx +1 -1
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +1 -2
- package/src/components/editor-2d/renderers/floorplan-annotation-layout.test.ts +0 -37
- package/src/components/editor-2d/renderers/floorplan-annotation-layout.ts +0 -30
- package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.tsx +13 -3
- package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +1 -2
- package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +8 -10
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +25 -33
- package/src/components/systems/roof/roof-edit-system.tsx +4 -3
- package/src/components/tools/distribution/distribution-run-tool.tsx +1 -1
- package/src/components/tools/item/placement-history.test.tsx +1 -1
- package/src/components/tools/item/use-placement-coordinator.tsx +2 -3
- package/src/components/tools/paint-region/wall-paint-region-tool.tsx +6 -2
- package/src/components/tools/registry/move-registry-node-tool.tsx +1 -2
- package/src/components/tools/shared/cursor-sphere.tsx +1 -1
- package/src/components/tools/shared/draft-measurement-label.tsx +1 -1
- package/src/components/tools/shared/placement-box.tsx +1 -1
- package/src/components/tools/shared/placement-dimension-guides.tsx +1 -1
- package/src/components/tools/site/terrain-brush-cursor.tsx +1 -1
- package/src/components/ui/action-menu/camera-actions.tsx +2 -2
- package/src/components/ui/action-menu/control-modes.tsx +1 -1
- package/src/components/ui/action-menu/follow-pascal-pill.tsx +1 -1
- package/src/components/ui/action-menu/index.tsx +3 -3
- package/src/components/ui/action-menu/measurement-control.tsx +3 -3
- package/src/components/ui/action-menu/placement.ts +0 -18
- package/src/components/ui/action-menu/point-ask-button.tsx +1 -1
- package/src/components/ui/action-menu/view-toggles.tsx +5 -5
- package/src/components/ui/controls/finish-swatch.tsx +1 -1
- package/src/components/ui/controls/material-paint-panel.tsx +2 -2
- package/src/components/ui/controls/scene-material-list.tsx +2 -2
- package/src/components/ui/controls/select-control.tsx +1 -1
- package/src/components/ui/controls/terrain-sculpt-panel.tsx +1 -1
- package/src/components/ui/floating-level-selector.tsx +3 -3
- package/src/components/ui/helpers/contextual-helper-panel.tsx +1 -1
- package/src/components/ui/helpers/helper-manager.test.tsx +1 -1
- package/src/components/ui/helpers/helper-manager.tsx +1 -1
- package/src/components/ui/level-duplicate-dialog.tsx +1 -1
- package/src/components/ui/node-icon-image.test.tsx +46 -0
- package/src/components/ui/node-icon-image.tsx +33 -1
- package/src/components/ui/panels/collections/collections-popover.tsx +2 -2
- package/src/components/ui/panels/floor-foundation-panel.tsx +2 -2
- package/src/components/ui/panels/floor-opening-panel.tsx +4 -4
- package/src/components/ui/panels/node-display.ts +1 -1
- package/src/components/ui/panels/panel-manager.tsx +1 -1
- package/src/components/ui/panels/panel-wrapper.tsx +1 -1
- package/src/components/ui/panels/parametric-inspector.tsx +1 -1
- package/src/components/ui/panels/room-construction-rows.tsx +6 -6
- package/src/components/ui/panels/room-name-options.ts +1 -5
- package/src/components/ui/panels/room-panel.tsx +6 -8
- package/src/components/ui/primitives/color-dot.tsx +2 -2
- package/src/components/ui/primitives/opacity-control.tsx +2 -2
- package/src/components/ui/primitives/sidebar.tsx +3 -3
- package/src/components/ui/right-stack.tsx +1 -1
- package/src/components/ui/sidebar/icon-rail.tsx +1 -1
- package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +1 -1
- package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +2 -2
- package/src/components/ui/sidebar/panels/settings-panel/construction-animation-setting.tsx +1 -1
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +2 -2
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +1 -1
- package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +1 -1
- package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +2 -2
- package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +46 -7
- package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/tree-node-drag.tsx +14 -5
- package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +2 -2
- package/src/components/ui/sidebar/panels/zone-panel/index.tsx +3 -3
- package/src/components/ui/sidebar/tab-bar.tsx +1 -1
- package/src/components/ui/wall-type/wall-type-editor-dialog.tsx +1 -1
- package/src/components/ui/wall-type/wall-type-menu.tsx +1 -1
- package/src/components/viewer-overlay.tsx +1 -2
- package/src/hooks/use-auto-frame.ts +1 -2
- package/src/hooks/use-keyboard.ts +1 -2
- package/src/hooks/use-selected-room.ts +5 -7
- package/src/i18n/floor-footprint-name.ts +1 -1
- package/src/i18n/locales/en/messages.po +180 -182
- package/src/i18n/locales/es/messages.po +180 -182
- package/src/i18n/locales/fr/messages.po +180 -182
- package/src/i18n/locales/ja/messages.po +180 -182
- package/src/i18n/locales/zh-CN/messages.po +180 -182
- package/src/i18n/locales.test.ts +1 -1
- package/src/index.tsx +109 -131
- package/src/lib/below-grade.test.tsx +4 -4
- package/src/lib/camera-facing.test.ts +1 -1
- package/src/lib/camera-facing.ts +2 -3
- package/src/lib/editor-level-display.ts +1 -1
- package/src/lib/floor-footprints.test.ts +2 -2
- package/src/lib/floor-opening-draft.ts +1 -1
- package/src/lib/floorplan/annotation-visibility.test.ts +1 -1
- package/src/lib/floorplan/annotation-visibility.ts +1 -1
- package/src/lib/floorplan/floorplan-export.test.ts +1 -1
- package/src/lib/floorplan/floorplan-export.tsx +8 -11
- package/src/lib/floorplan/floorplan-extension.ts +4 -157
- package/src/lib/floorplan/floorplan-mode.ts +2 -1
- package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +1 -1
- package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +8 -6
- package/src/lib/floorplan/floorplan-plugin-install.test.tsx +3 -4
- package/src/lib/floorplan/geometry.ts +2 -112
- package/src/lib/floorplan/index.ts +0 -9
- package/src/lib/floorplan/items.ts +2 -1
- package/src/lib/floorplan/schedules.ts +6 -3
- package/src/lib/floorplan/sheet-export.ts +7 -3
- package/src/lib/floorplan/site-plan/site-parts.ts +7 -1
- package/src/lib/floorplan/site-plan/site-plan-layer.tsx +1 -1
- package/src/lib/follow-pascal-presentation.ts +1 -1
- package/src/lib/glb-export.ts +1 -1
- package/src/lib/material-paint.ts +1 -1
- package/src/lib/measurements.test.ts +2 -1
- package/src/lib/mezzanine-selection.test.ts +1 -1
- package/src/lib/paint-region-mode.ts +1 -1
- package/src/lib/point-ask/session.ts +1 -2
- package/src/lib/roof-duplication.test.ts +100 -0
- package/src/lib/roof-duplication.ts +30 -5
- package/src/lib/room-handle-drag.ts +2 -12
- package/src/lib/room-name-catalog.test.ts +2 -1
- package/src/lib/room-name-messages.test.ts +2 -1
- package/src/lib/room-name-messages.ts +1 -118
- package/src/lib/room-name-storage.test.ts +2 -1
- package/src/lib/room-selection-commands.ts +2 -2
- package/src/lib/room-selection.test.ts +6 -6
- package/src/lib/room-transform-session.ts +1 -2
- package/src/lib/room-zone-routing.ts +2 -2
- package/src/lib/selection-routing.test.ts +0 -28
- package/src/lib/selection-routing.ts +1 -40
- package/src/lib/sfx-bus.ts +8 -32
- package/src/lib/sfx-player.test.ts +72 -1
- package/src/lib/sfx-player.ts +49 -337
- package/src/lib/viewer-selection.test.ts +38 -10
- package/src/lib/wall-pull.test.ts +1 -1
- package/src/lib/wall-pull.ts +1 -27
- package/src/store/use-editor.tsx +17 -26
- package/src/store/use-floorplan-annotation-visibility.ts +5 -5
- package/src/store/use-wall-region-handles-selection.ts +1 -2
- package/src/components/editor/__bench__/room-label-placement.bench.ts +0 -108
- package/src/components/editor/first-person/build-collider-world.test.ts +0 -596
- package/src/components/editor/first-person/build-collider-world.ts +0 -549
- package/src/components/editor/first-person/ground-holes.test.ts +0 -128
- package/src/components/editor/first-person/ground-holes.ts +0 -130
- package/src/components/editor/first-person/mechanism-targets.test.ts +0 -162
- package/src/components/editor/first-person/mechanism-targets.ts +0 -88
- package/src/components/editor/first-person/terrain-collider.test.ts +0 -132
- package/src/components/editor/first-person/terrain-collider.ts +0 -212
- package/src/components/editor/floorplan-camera-sync.test.ts +0 -249
- package/src/components/editor/floorplan-camera-sync.ts +0 -213
- package/src/components/editor/localized-html.tsx +0 -22
- package/src/components/editor-2d/renderers/floorplan-dimension-renderer.test.tsx +0 -236
- package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +0 -708
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +0 -385
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +0 -670
- package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +0 -89
- package/src/components/editor-2d/renderers/floorplan-label-angle.ts +0 -106
- package/src/components/editor-2d/svg-paths.ts +0 -124
- package/src/components/ui/action-menu/action-button.tsx +0 -89
- package/src/components/ui/action-menu/follow-pascal-toggle.tsx +0 -42
- package/src/components/ui/primitives/button.tsx +0 -69
- package/src/components/ui/primitives/dropdown-menu.tsx +0 -232
- package/src/components/ui/primitives/popover.tsx +0 -42
- package/src/components/ui/primitives/switch.tsx +0 -29
- package/src/components/ui/primitives/tooltip.tsx +0 -57
- package/src/components/ui/scene-loader.tsx +0 -73
- package/src/components/viewer/floorplan-compass-button.tsx +0 -67
- package/src/components/viewer/floorplan-preview-geometry.test.ts +0 -107
- package/src/components/viewer/floorplan-preview-geometry.ts +0 -281
- package/src/components/viewer/floorplan-preview-navigation.test.ts +0 -43
- package/src/components/viewer/floorplan-preview-navigation.ts +0 -62
- package/src/components/viewer/floorplan-preview-visibility.test.ts +0 -35
- package/src/components/viewer/floorplan-preview-visibility.ts +0 -19
- package/src/components/viewer/floorplan-preview.tsx +0 -1065
- package/src/components/viewer/render-settings-menu.tsx +0 -294
- package/src/components/viewer/sky-control.tsx +0 -710
- package/src/components/viewer/use-viewer-camera-navigation-sync.ts +0 -140
- package/src/components/viewer/use-viewer-ui-sounds.test.ts +0 -15
- package/src/components/viewer/use-viewer-ui-sounds.ts +0 -46
- package/src/components/viewer/viewer-controls-bar.tsx +0 -496
- package/src/components/viewer/viewer-hover-label.tsx +0 -42
- package/src/components/viewer/viewer-rooms.tsx +0 -248
- package/src/components/viewer/viewer-scene-header.tsx +0 -286
- package/src/components/viewer/viewer-selection-manager.tsx +0 -124
- package/src/components/viewer/viewer-stage-modes.test.ts +0 -38
- package/src/components/viewer/viewer-stage-modes.ts +0 -49
- package/src/components/viewer/viewer-stage-switcher.tsx +0 -97
- package/src/components/viewer/viewer-stage.test.tsx +0 -47
- package/src/components/viewer/viewer-stage.tsx +0 -221
- package/src/components/walkthrough-hud.tsx +0 -193
- package/src/hooks/use-mobile.ts +0 -19
- package/src/i18n/level-name.ts +0 -39
- package/src/i18n/scene-theme-name.ts +0 -16
- package/src/lib/below-grade.ts +0 -133
- package/src/lib/camera-pose.test.ts +0 -237
- package/src/lib/camera-pose.ts +0 -172
- package/src/lib/floorplan/floorplan-extension.test.ts +0 -42
- package/src/lib/floorplan/floorplan-readonly.test.ts +0 -47
- package/src/lib/floorplan/floorplan-readonly.ts +0 -95
- package/src/lib/floorplan/marks.test.ts +0 -213
- package/src/lib/floorplan/marks.ts +0 -238
- package/src/lib/measurements.ts +0 -8
- package/src/lib/node-display-label.ts +0 -37
- package/src/lib/room-assembly-overlay.test.ts +0 -275
- package/src/lib/room-assembly-overlay.ts +0 -265
- package/src/lib/room-selection.ts +0 -417
- package/src/lib/scene-bounds.test.ts +0 -327
- package/src/lib/scene-bounds.ts +0 -233
- package/src/lib/typing-target.ts +0 -9
- package/src/lib/viewer-selection.ts +0 -595
- package/src/lib/zone-content.ts +0 -133
- package/src/store/camera-pose-store.test.ts +0 -29
- package/src/store/camera-pose-store.ts +0 -27
- package/src/store/navigation-sync-pose-store.test.ts +0 -34
- package/src/store/navigation-sync-pose-store.ts +0 -27
- package/src/store/use-audio.test.ts +0 -146
- package/src/store/use-audio.tsx +0 -169
- package/src/store/use-first-person-hud.ts +0 -40
- package/src/store/use-follow-pascal.test.ts +0 -62
- package/src/store/use-follow-pascal.ts +0 -53
|
@@ -1,710 +0,0 @@
|
|
|
1
|
-
'use client'
|
|
2
|
-
|
|
3
|
-
import { type PascalI18n, usePascalI18n, useScene } from '@pascal-app/core'
|
|
4
|
-
import {
|
|
5
|
-
presetInstant,
|
|
6
|
-
requestDeviceLocation,
|
|
7
|
-
resolveLocation,
|
|
8
|
-
type SkyPhase,
|
|
9
|
-
skyPhase,
|
|
10
|
-
solarClockInstant,
|
|
11
|
-
solarClockMinutes,
|
|
12
|
-
solarPosition,
|
|
13
|
-
sunEvents,
|
|
14
|
-
type TimeOfDay,
|
|
15
|
-
useViewer,
|
|
16
|
-
WEATHER_COVER,
|
|
17
|
-
type Weather,
|
|
18
|
-
} from '@pascal-app/viewer'
|
|
19
|
-
import {
|
|
20
|
-
Cloud,
|
|
21
|
-
CloudSun,
|
|
22
|
-
Cloudy,
|
|
23
|
-
LocateFixed,
|
|
24
|
-
Moon,
|
|
25
|
-
RotateCcw,
|
|
26
|
-
Sun,
|
|
27
|
-
Sunrise,
|
|
28
|
-
Sunset,
|
|
29
|
-
X,
|
|
30
|
-
} from 'lucide-react'
|
|
31
|
-
import {
|
|
32
|
-
type CSSProperties,
|
|
33
|
-
type PointerEvent,
|
|
34
|
-
useEffect,
|
|
35
|
-
useLayoutEffect,
|
|
36
|
-
useMemo,
|
|
37
|
-
useRef,
|
|
38
|
-
useState,
|
|
39
|
-
} from 'react'
|
|
40
|
-
import { create } from 'zustand'
|
|
41
|
-
import { cn } from '../../lib/utils'
|
|
42
|
-
import { Popover, PopoverAnchor, PopoverContent } from '../ui/primitives/popover'
|
|
43
|
-
import { Switch } from '../ui/primitives/switch'
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* The sky (time of day and weather) as a chip beside the compass, opening a panel: the sun's
|
|
47
|
-
* arc to drag through the day, the presets, the live clock, and the cloud cover. While a chip is
|
|
48
|
-
* mounted the display menus point here instead of carrying their own time and weather menus.
|
|
49
|
-
*/
|
|
50
|
-
export const useSkyPanel = create<{ open: boolean; hosts: number }>(() => ({
|
|
51
|
-
open: false,
|
|
52
|
-
hosts: 0,
|
|
53
|
-
}))
|
|
54
|
-
|
|
55
|
-
export function openSkyPanel() {
|
|
56
|
-
useSkyPanel.setState({ open: true })
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* The bottom docks the chip lines up with (measured): the editor's tool dock and the viewer's
|
|
61
|
-
* controls bar, both 24 px up. The compass beside the chip centres on them.
|
|
62
|
-
*/
|
|
63
|
-
export const SKY_DOCK_BOTTOM = 24
|
|
64
|
-
export const SKY_DOCK_HEIGHT = { editor: 58, viewer: 56 } as const
|
|
65
|
-
export const SKY_COMPASS_BOTTOM = 36
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* A preset or reset eases the sun to its time the short way round the clock, slowly enough to
|
|
69
|
-
* watch the light change: 1.5 s for a short hop, up to 3 s for half a day, easing in and out.
|
|
70
|
-
*/
|
|
71
|
-
const TWEEN_MIN_MS = 1500
|
|
72
|
-
const TWEEN_MAX_MS = 3000
|
|
73
|
-
let tween = 0
|
|
74
|
-
|
|
75
|
-
function stopTween() {
|
|
76
|
-
cancelAnimationFrame(tween)
|
|
77
|
-
tween = 0
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
function easeTimeTo(from: number, to: number, done: () => void) {
|
|
81
|
-
stopTween()
|
|
82
|
-
const reduce = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
|
|
83
|
-
const delta = ((((to - from + 2160) % 1440) + 1440) % 1440) - 720
|
|
84
|
-
if (reduce || Math.abs(delta) < 1) {
|
|
85
|
-
done()
|
|
86
|
-
return
|
|
87
|
-
}
|
|
88
|
-
const start = performance.now()
|
|
89
|
-
const duration = TWEEN_MIN_MS + (TWEEN_MAX_MS - TWEEN_MIN_MS) * (Math.abs(delta) / 720)
|
|
90
|
-
const step = (now: number) => {
|
|
91
|
-
const t = Math.min(1, (now - start) / duration)
|
|
92
|
-
const eased = t < 0.5 ? 4 * t ** 3 : 1 - (-2 * t + 2) ** 3 / 2
|
|
93
|
-
if (t >= 1) {
|
|
94
|
-
tween = 0
|
|
95
|
-
done()
|
|
96
|
-
return
|
|
97
|
-
}
|
|
98
|
-
useViewer.getState().setTimeOfDayMinutes((((from + delta * eased) % 1440) + 1440) % 1440)
|
|
99
|
-
tween = requestAnimationFrame(step)
|
|
100
|
-
}
|
|
101
|
-
tween = requestAnimationFrame(step)
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
const PRESETS: readonly { id: TimeOfDay; icon: typeof Sun }[] = [
|
|
105
|
-
{ id: 'midday', icon: Sun },
|
|
106
|
-
{ id: 'golden', icon: Sunset },
|
|
107
|
-
{ id: 'blue', icon: Sunrise },
|
|
108
|
-
{ id: 'night', icon: Moon },
|
|
109
|
-
]
|
|
110
|
-
|
|
111
|
-
const WEATHERS: readonly { id: Weather; icon: typeof Sun }[] = [
|
|
112
|
-
{ id: 'clear', icon: Sun },
|
|
113
|
-
{ id: 'fair', icon: CloudSun },
|
|
114
|
-
{ id: 'cloudy', icon: Cloudy },
|
|
115
|
-
{ id: 'overcast', icon: Cloud },
|
|
116
|
-
]
|
|
117
|
-
|
|
118
|
-
export function weatherIcon(weather: Weather) {
|
|
119
|
-
return WEATHERS.find((option) => option.id === weather)?.icon ?? CloudSun
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
function presetName(i18n: PascalI18n, id: TimeOfDay) {
|
|
123
|
-
switch (id) {
|
|
124
|
-
case 'golden':
|
|
125
|
-
return i18n._({ id: 'viewer.daylight.time.golden', message: 'Golden hour' })
|
|
126
|
-
case 'midday':
|
|
127
|
-
return i18n._({ id: 'viewer.daylight.time.midday', message: 'Midday' })
|
|
128
|
-
case 'blue':
|
|
129
|
-
return i18n._({ id: 'viewer.daylight.time.blue', message: 'Blue hour' })
|
|
130
|
-
case 'night':
|
|
131
|
-
return i18n._({ id: 'viewer.daylight.time.night', message: 'Night' })
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
function phaseName(i18n: PascalI18n, phase: SkyPhase) {
|
|
136
|
-
switch (phase) {
|
|
137
|
-
case 'night':
|
|
138
|
-
return presetName(i18n, 'night')
|
|
139
|
-
case 'blue':
|
|
140
|
-
return presetName(i18n, 'blue')
|
|
141
|
-
case 'golden':
|
|
142
|
-
return presetName(i18n, 'golden')
|
|
143
|
-
case 'midday':
|
|
144
|
-
return presetName(i18n, 'midday')
|
|
145
|
-
case 'morning':
|
|
146
|
-
return i18n._({ id: 'viewer.daylight.phase.morning', message: 'Morning' })
|
|
147
|
-
case 'afternoon':
|
|
148
|
-
return i18n._({ id: 'viewer.daylight.phase.afternoon', message: 'Afternoon' })
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
export function weatherName(i18n: PascalI18n, id: Weather) {
|
|
153
|
-
switch (id) {
|
|
154
|
-
case 'clear':
|
|
155
|
-
return i18n._({ id: 'viewer.daylight.weather.clear', message: 'Clear' })
|
|
156
|
-
case 'fair':
|
|
157
|
-
return i18n._({ id: 'viewer.daylight.weather.fair', message: 'Fair' })
|
|
158
|
-
case 'cloudy':
|
|
159
|
-
return i18n._({ id: 'viewer.daylight.weather.cloudy', message: 'Cloudy' })
|
|
160
|
-
case 'overcast':
|
|
161
|
-
return i18n._({ id: 'viewer.daylight.weather.overcast', message: 'Overcast' })
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
/** Sky colours (zenith, horizon) per phase, for the chip swatch and the arc's backdrop. */
|
|
166
|
-
const PHASE_SKY: Record<SkyPhase, [string, string]> = {
|
|
167
|
-
night: ['#121a36', '#36406c'],
|
|
168
|
-
blue: ['#263465', '#8c88b4'],
|
|
169
|
-
golden: ['#8e98c6', '#f2cfb2'],
|
|
170
|
-
morning: ['#6f9dd6', '#dbe7f2'],
|
|
171
|
-
midday: ['#5a93d5', '#d3e4f3'],
|
|
172
|
-
afternoon: ['#6a98d0', '#e6e2da'],
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
/** Minutes after midnight, as the locale writes a time of day. */
|
|
176
|
-
const formatMinutes = (() => {
|
|
177
|
-
let format: Intl.DateTimeFormat | null = null
|
|
178
|
-
return (minutes: number) => {
|
|
179
|
-
format ??= new Intl.DateTimeFormat(undefined, {
|
|
180
|
-
hour: 'numeric',
|
|
181
|
-
minute: '2-digit',
|
|
182
|
-
timeZone: 'UTC',
|
|
183
|
-
})
|
|
184
|
-
return format.format(new Date(Math.round(minutes) * 60_000))
|
|
185
|
-
}
|
|
186
|
-
})()
|
|
187
|
-
|
|
188
|
-
type SiteFacts = Parameters<typeof resolveLocation>[0]
|
|
189
|
-
|
|
190
|
-
/** The sky the viewer shows right now: its time on the site's sun clock, phase and events. */
|
|
191
|
-
function useSky() {
|
|
192
|
-
const timeOfDay = useViewer((s) => s.timeOfDay)
|
|
193
|
-
const timeOfDayMinutes = useViewer((s) => s.timeOfDayMinutes)
|
|
194
|
-
const skyLive = useViewer((s) => s.skyLive)
|
|
195
|
-
const weather = useViewer((s) => s.weather)
|
|
196
|
-
const deviceLocation = useViewer((s) => s.deviceLocation)
|
|
197
|
-
const site = useScene((state) => {
|
|
198
|
-
for (const id of state.rootNodeIds) {
|
|
199
|
-
const node = state.nodes[id]
|
|
200
|
-
if (node?.type === 'site') return node as unknown as SiteFacts
|
|
201
|
-
}
|
|
202
|
-
return undefined
|
|
203
|
-
})
|
|
204
|
-
// The live clock moves the chip on once a minute.
|
|
205
|
-
const [now, setNow] = useState(() => new Date())
|
|
206
|
-
useEffect(() => {
|
|
207
|
-
if (!skyLive) return
|
|
208
|
-
setNow(new Date())
|
|
209
|
-
const timer = setInterval(() => setNow(new Date()), 60_000)
|
|
210
|
-
return () => clearInterval(timer)
|
|
211
|
-
}, [skyLive])
|
|
212
|
-
|
|
213
|
-
const { location, source } = resolveLocation(site, deviceLocation)
|
|
214
|
-
const { lat, lng } = location
|
|
215
|
-
const day = useMemo(() => {
|
|
216
|
-
const today = new Date()
|
|
217
|
-
const presets = Object.fromEntries(
|
|
218
|
-
PRESETS.map(({ id }) => [id, solarClockMinutes(presetInstant(today, lat, lng, id), lng)]),
|
|
219
|
-
) as Record<TimeOfDay, number>
|
|
220
|
-
return { today, presets, events: sunEvents(today, lat, lng) }
|
|
221
|
-
}, [lat, lng])
|
|
222
|
-
|
|
223
|
-
const minutes = skyLive
|
|
224
|
-
? solarClockMinutes(now, location.lng)
|
|
225
|
-
: (timeOfDayMinutes ?? day.presets[timeOfDay])
|
|
226
|
-
const elevation = solarPosition(
|
|
227
|
-
solarClockInstant(day.today, location.lng, minutes),
|
|
228
|
-
location.lat,
|
|
229
|
-
location.lng,
|
|
230
|
-
).elevation
|
|
231
|
-
return {
|
|
232
|
-
minutes,
|
|
233
|
-
// The live sky reads the wall clock the person knows; set times read the site's sun clock.
|
|
234
|
-
clock: skyLive ? now.getHours() * 60 + now.getMinutes() : minutes,
|
|
235
|
-
phase: skyPhase(elevation, minutes),
|
|
236
|
-
activePreset: skyLive || timeOfDayMinutes !== null ? null : timeOfDay,
|
|
237
|
-
presets: day.presets,
|
|
238
|
-
events: day.events ?? { sunrise: 6 * 60, sunset: 18 * 60 },
|
|
239
|
-
skyLive,
|
|
240
|
-
weather,
|
|
241
|
-
source,
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
/**
|
|
246
|
-
* The chip and its panel, absolutely placed by the caller (the scene's bottom-left corner, beside
|
|
247
|
-
* the compass). `dark` for the viewer's dark chrome.
|
|
248
|
-
*/
|
|
249
|
-
export function SkyControl({
|
|
250
|
-
className,
|
|
251
|
-
dark = false,
|
|
252
|
-
height,
|
|
253
|
-
style,
|
|
254
|
-
}: {
|
|
255
|
-
className?: string
|
|
256
|
-
dark?: boolean
|
|
257
|
-
/** The dock's height beside it, so the two line up. */
|
|
258
|
-
height: number
|
|
259
|
-
style?: CSSProperties
|
|
260
|
-
}) {
|
|
261
|
-
const i18n = usePascalI18n()
|
|
262
|
-
const open = useSkyPanel((s) => s.open)
|
|
263
|
-
const sky = useSky()
|
|
264
|
-
const WeatherIcon = weatherIcon(sky.weather)
|
|
265
|
-
const nextWeather =
|
|
266
|
-
WEATHERS[(WEATHERS.findIndex((w) => w.id === sky.weather) + 1) % WEATHERS.length]!
|
|
267
|
-
const chip = useRef<HTMLDivElement | null>(null)
|
|
268
|
-
const compact = useClearOfDock(chip)
|
|
269
|
-
|
|
270
|
-
useEffect(() => {
|
|
271
|
-
useSkyPanel.setState((s) => ({ hosts: s.hosts + 1 }))
|
|
272
|
-
return () => useSkyPanel.setState((s) => ({ hosts: s.hosts - 1, open: false }))
|
|
273
|
-
}, [])
|
|
274
|
-
|
|
275
|
-
return (
|
|
276
|
-
<Popover onOpenChange={(next) => useSkyPanel.setState({ open: next })} open={open}>
|
|
277
|
-
<PopoverAnchor asChild>
|
|
278
|
-
<div
|
|
279
|
-
className={cn(
|
|
280
|
-
'corner-smooth pointer-events-auto absolute z-30 flex items-stretch gap-0.5 rounded-2xl border border-border/40 bg-background/95 p-1 text-foreground text-xs shadow-elevation-4 backdrop-blur-xl',
|
|
281
|
-
dark && 'dark',
|
|
282
|
-
className,
|
|
283
|
-
)}
|
|
284
|
-
onPointerDown={(event) => event.stopPropagation()}
|
|
285
|
-
ref={chip}
|
|
286
|
-
style={{ bottom: SKY_DOCK_BOTTOM, height, ...style }}
|
|
287
|
-
>
|
|
288
|
-
<button
|
|
289
|
-
aria-expanded={open}
|
|
290
|
-
aria-label={i18n._({ id: 'viewer.daylight.sky.open', message: 'Time and weather' })}
|
|
291
|
-
className={cn(
|
|
292
|
-
'flex items-center gap-2 rounded-xl pr-3 pl-1.5 transition-colors',
|
|
293
|
-
open ? 'bg-violet-500/10' : 'hover:bg-accent',
|
|
294
|
-
)}
|
|
295
|
-
onClick={() => useSkyPanel.setState({ open: !open })}
|
|
296
|
-
type="button"
|
|
297
|
-
>
|
|
298
|
-
<MiniSky sky={sky} />
|
|
299
|
-
<span className="font-mono text-foreground text-sm tabular-nums">
|
|
300
|
-
{formatMinutes(sky.clock)}
|
|
301
|
-
</span>
|
|
302
|
-
{compact ? null : (
|
|
303
|
-
<span className="text-muted-foreground text-sm">{phaseName(i18n, sky.phase)}</span>
|
|
304
|
-
)}
|
|
305
|
-
</button>
|
|
306
|
-
<span aria-hidden="true" className="mx-0.5 my-auto h-6 w-px bg-border" />
|
|
307
|
-
<button
|
|
308
|
-
aria-label={i18n._({
|
|
309
|
-
id: 'viewer.daylight.sky.cycleWeather',
|
|
310
|
-
message: 'Cloud cover: {current}. Change to {next}',
|
|
311
|
-
values: {
|
|
312
|
-
current: weatherName(i18n, sky.weather),
|
|
313
|
-
next: weatherName(i18n, nextWeather.id),
|
|
314
|
-
},
|
|
315
|
-
})}
|
|
316
|
-
className="flex items-center gap-1.5 rounded-xl px-2.5 text-sm transition-colors hover:bg-accent"
|
|
317
|
-
onClick={() => useViewer.getState().setWeather(nextWeather.id)}
|
|
318
|
-
title={i18n._({
|
|
319
|
-
id: 'viewer.daylight.sky.cycleWeatherTip',
|
|
320
|
-
message: 'Cycle cloud cover',
|
|
321
|
-
})}
|
|
322
|
-
type="button"
|
|
323
|
-
>
|
|
324
|
-
<WeatherIcon aria-hidden="true" className="h-[18px] w-[18px] text-muted-foreground" />
|
|
325
|
-
{compact ? null : <span>{weatherName(i18n, sky.weather)}</span>}
|
|
326
|
-
</button>
|
|
327
|
-
</div>
|
|
328
|
-
</PopoverAnchor>
|
|
329
|
-
<PopoverContent
|
|
330
|
-
align="start"
|
|
331
|
-
className={cn('w-[22rem] rounded-2xl p-4', dark && 'dark')}
|
|
332
|
-
// Opened from a menu, focus returns to that menu's trigger: only a click outside closes.
|
|
333
|
-
onFocusOutside={(event) => event.preventDefault()}
|
|
334
|
-
onOpenAutoFocus={(event) => event.preventDefault()}
|
|
335
|
-
onPointerDown={(event) => event.stopPropagation()}
|
|
336
|
-
side="top"
|
|
337
|
-
sideOffset={8}
|
|
338
|
-
>
|
|
339
|
-
<SkyPanel sky={sky} />
|
|
340
|
-
</PopoverContent>
|
|
341
|
-
</Popover>
|
|
342
|
-
)
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
/**
|
|
346
|
-
* Whether the chip must drop its phase and weather labels to stay clear of the bottom dock (centred
|
|
347
|
-
* on the window, so a sidebar or a narrow pane brings it close). Measures the full chip, then
|
|
348
|
-
* compares where it would end with where the dock starts.
|
|
349
|
-
*/
|
|
350
|
-
function useClearOfDock(chip: { current: HTMLDivElement | null }): boolean {
|
|
351
|
-
const [compact, setCompact] = useState(false)
|
|
352
|
-
const fullWidth = useRef(0)
|
|
353
|
-
useLayoutEffect(() => {
|
|
354
|
-
const element = chip.current
|
|
355
|
-
if (!element) return
|
|
356
|
-
const check = () => {
|
|
357
|
-
const box = element.getBoundingClientRect()
|
|
358
|
-
if (!compact) fullWidth.current = box.width
|
|
359
|
-
const dock = document.querySelector(
|
|
360
|
-
'[data-surface="tool-dock"], [data-surface="viewer-dock"]',
|
|
361
|
-
)
|
|
362
|
-
const dockBox = dock?.getBoundingClientRect()
|
|
363
|
-
const overlaps =
|
|
364
|
-
!!dockBox &&
|
|
365
|
-
dockBox.width > 0 &&
|
|
366
|
-
Math.abs(dockBox.bottom - box.bottom) < 40 &&
|
|
367
|
-
box.left + fullWidth.current + 12 > dockBox.left
|
|
368
|
-
if (overlaps !== compact) setCompact(overlaps)
|
|
369
|
-
}
|
|
370
|
-
check()
|
|
371
|
-
const observer = new ResizeObserver(check)
|
|
372
|
-
observer.observe(element)
|
|
373
|
-
if (element.offsetParent) observer.observe(element.offsetParent)
|
|
374
|
-
window.addEventListener('resize', check)
|
|
375
|
-
return () => {
|
|
376
|
-
observer.disconnect()
|
|
377
|
-
window.removeEventListener('resize', check)
|
|
378
|
-
}
|
|
379
|
-
})
|
|
380
|
-
return compact
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
/** The chip's swatch: the sky's colours with the sun (or moon) where it stands on its arc. */
|
|
384
|
-
function MiniSky({ sky }: { sky: ReturnType<typeof useSky> }) {
|
|
385
|
-
const [top, horizon] = PHASE_SKY[sky.phase]
|
|
386
|
-
const sun = arcPoint(sky.minutes, sky.events.sunrise, sky.events.sunset)
|
|
387
|
-
return (
|
|
388
|
-
<span
|
|
389
|
-
aria-hidden="true"
|
|
390
|
-
className="relative h-8 w-8 shrink-0 overflow-hidden rounded-lg border border-black/10"
|
|
391
|
-
style={{ background: `linear-gradient(${top}, ${horizon} 62%, #8a8a94 63%, #6c6c7a)` }}
|
|
392
|
-
>
|
|
393
|
-
<span
|
|
394
|
-
className="absolute h-2 w-2 -translate-x-1/2 -translate-y-1/2 rounded-full"
|
|
395
|
-
style={{
|
|
396
|
-
left: `${(sun.x / W) * 100}%`,
|
|
397
|
-
top: `${Math.min(92, (sun.y / H) * 100)}%`,
|
|
398
|
-
background: sun.day ? '#fcd165' : '#d6dcf0',
|
|
399
|
-
boxShadow: sun.day ? '0 0 4px 1px rgba(252, 209, 101, 0.6)' : 'none',
|
|
400
|
-
}}
|
|
401
|
-
/>
|
|
402
|
-
</span>
|
|
403
|
-
)
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
function SkyPanel({ sky }: { sky: ReturnType<typeof useSky> }) {
|
|
407
|
-
const i18n = usePascalI18n()
|
|
408
|
-
const [locating, setLocating] = useState(false)
|
|
409
|
-
const viewer = useViewer.getState
|
|
410
|
-
return (
|
|
411
|
-
<div className="flex flex-col gap-3">
|
|
412
|
-
<div className="flex items-start gap-2">
|
|
413
|
-
<div className="flex-1">
|
|
414
|
-
<div className="font-medium text-foreground text-sm">
|
|
415
|
-
{i18n._({ id: 'viewer.daylight.sky.title', message: 'Sky' })}
|
|
416
|
-
</div>
|
|
417
|
-
<div className="text-muted-foreground text-xs">
|
|
418
|
-
{i18n._({
|
|
419
|
-
id: 'viewer.daylight.sky.subtitle',
|
|
420
|
-
message: 'Light and weather for this view',
|
|
421
|
-
})}
|
|
422
|
-
</div>
|
|
423
|
-
</div>
|
|
424
|
-
<button
|
|
425
|
-
className="flex items-center gap-1 rounded-md px-1.5 py-1 text-violet-600 text-xs hover:bg-accent dark:text-violet-300"
|
|
426
|
-
onClick={() => {
|
|
427
|
-
easeTimeTo(sky.minutes, sky.presets.midday, () => viewer().setTimeOfDay('midday'))
|
|
428
|
-
viewer().setWeather('fair')
|
|
429
|
-
}}
|
|
430
|
-
type="button"
|
|
431
|
-
>
|
|
432
|
-
<RotateCcw className="h-3.5 w-3.5" />
|
|
433
|
-
{i18n._({ id: 'viewer.daylight.sky.reset', message: 'Reset' })}
|
|
434
|
-
</button>
|
|
435
|
-
<button
|
|
436
|
-
aria-label={i18n._({ id: 'viewer.daylight.sky.close', message: 'Close' })}
|
|
437
|
-
className="rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
438
|
-
onClick={() => useSkyPanel.setState({ open: false })}
|
|
439
|
-
type="button"
|
|
440
|
-
>
|
|
441
|
-
<X className="h-4 w-4" />
|
|
442
|
-
</button>
|
|
443
|
-
</div>
|
|
444
|
-
|
|
445
|
-
<div className="flex items-center gap-2">
|
|
446
|
-
<span className="flex-1 font-medium text-[11px] text-muted-foreground uppercase tracking-wide">
|
|
447
|
-
{i18n._({ id: 'viewer.daylight.time.title', message: 'Time of day' })}
|
|
448
|
-
</span>
|
|
449
|
-
<span className="font-mono text-foreground text-sm tabular-nums">
|
|
450
|
-
{formatMinutes(sky.clock)}
|
|
451
|
-
</span>
|
|
452
|
-
<span className="rounded-full bg-muted px-2 py-0.5 text-foreground text-xs">
|
|
453
|
-
{phaseName(i18n, sky.phase)}
|
|
454
|
-
</span>
|
|
455
|
-
</div>
|
|
456
|
-
|
|
457
|
-
<SunArc sky={sky} />
|
|
458
|
-
|
|
459
|
-
<div className="-mt-1 flex justify-between text-muted-foreground text-xs">
|
|
460
|
-
<span className="flex items-center gap-1">
|
|
461
|
-
<Sunrise className="h-3.5 w-3.5" />
|
|
462
|
-
{i18n._({ id: 'viewer.daylight.sky.sunrise', message: 'Sunrise' })}
|
|
463
|
-
<span className="font-mono text-foreground tabular-nums">
|
|
464
|
-
{formatMinutes(sky.events.sunrise)}
|
|
465
|
-
</span>
|
|
466
|
-
</span>
|
|
467
|
-
<span className="flex items-center gap-1">
|
|
468
|
-
{i18n._({ id: 'viewer.daylight.sky.sunset', message: 'Sunset' })}
|
|
469
|
-
<span className="font-mono text-foreground tabular-nums">
|
|
470
|
-
{formatMinutes(sky.events.sunset)}
|
|
471
|
-
</span>
|
|
472
|
-
<Sunset className="h-3.5 w-3.5" />
|
|
473
|
-
</span>
|
|
474
|
-
</div>
|
|
475
|
-
|
|
476
|
-
<div className="grid grid-cols-4 gap-2">
|
|
477
|
-
{PRESETS.map((preset) => (
|
|
478
|
-
<OptionCard
|
|
479
|
-
active={sky.activePreset === preset.id}
|
|
480
|
-
detail={formatMinutes(sky.presets[preset.id])}
|
|
481
|
-
icon={preset.icon}
|
|
482
|
-
key={preset.id}
|
|
483
|
-
name={presetName(i18n, preset.id)}
|
|
484
|
-
onSelect={() =>
|
|
485
|
-
easeTimeTo(sky.minutes, sky.presets[preset.id], () =>
|
|
486
|
-
viewer().setTimeOfDay(preset.id),
|
|
487
|
-
)
|
|
488
|
-
}
|
|
489
|
-
/>
|
|
490
|
-
))}
|
|
491
|
-
</div>
|
|
492
|
-
|
|
493
|
-
<div className="flex items-center gap-3 rounded-xl bg-muted/60 px-3 py-2.5">
|
|
494
|
-
<LocateFixed className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
495
|
-
<div className="flex-1">
|
|
496
|
-
<div className="text-foreground text-sm">
|
|
497
|
-
{sky.source === 'site'
|
|
498
|
-
? i18n._({ id: 'viewer.daylight.sky.followClock', message: 'Follow the clock' })
|
|
499
|
-
: i18n._({ id: 'viewer.daylight.location.use', message: 'Use my location' })}
|
|
500
|
-
</div>
|
|
501
|
-
<div className="text-muted-foreground text-xs">
|
|
502
|
-
{sky.source === 'site'
|
|
503
|
-
? i18n._({
|
|
504
|
-
id: 'viewer.daylight.sky.followClockDetail',
|
|
505
|
-
message: 'The sun moves with your local clock.',
|
|
506
|
-
})
|
|
507
|
-
: i18n._({
|
|
508
|
-
id: 'viewer.daylight.sky.liveDetail',
|
|
509
|
-
message: 'Follows your local clock. Sun position is approximate.',
|
|
510
|
-
})}
|
|
511
|
-
</div>
|
|
512
|
-
</div>
|
|
513
|
-
<Switch
|
|
514
|
-
checked={sky.skyLive}
|
|
515
|
-
disabled={locating}
|
|
516
|
-
onCheckedChange={(live) => {
|
|
517
|
-
stopTween()
|
|
518
|
-
viewer().setSkyLive(live)
|
|
519
|
-
if (!live || sky.source === 'site' || sky.source === 'device') return
|
|
520
|
-
setLocating(true)
|
|
521
|
-
requestDeviceLocation()
|
|
522
|
-
.then((location) => useViewer.getState().setDeviceLocation(location))
|
|
523
|
-
.catch(() => {})
|
|
524
|
-
.finally(() => setLocating(false))
|
|
525
|
-
}}
|
|
526
|
-
/>
|
|
527
|
-
</div>
|
|
528
|
-
|
|
529
|
-
<div className="h-px bg-border" />
|
|
530
|
-
|
|
531
|
-
<div className="flex items-center">
|
|
532
|
-
<span className="flex-1 font-medium text-[11px] text-muted-foreground uppercase tracking-wide">
|
|
533
|
-
{i18n._({ id: 'viewer.daylight.sky.cloudCover', message: 'Cloud cover' })}
|
|
534
|
-
</span>
|
|
535
|
-
<span className="text-foreground text-sm">{weatherName(i18n, sky.weather)}</span>
|
|
536
|
-
</div>
|
|
537
|
-
<div className="grid grid-cols-4 gap-2">
|
|
538
|
-
{WEATHERS.map((option) => (
|
|
539
|
-
<OptionCard
|
|
540
|
-
active={sky.weather === option.id}
|
|
541
|
-
detail={`${Math.round(WEATHER_COVER[option.id] * 100)}%`}
|
|
542
|
-
icon={option.icon}
|
|
543
|
-
key={option.id}
|
|
544
|
-
name={weatherName(i18n, option.id)}
|
|
545
|
-
onSelect={() => viewer().setWeather(option.id)}
|
|
546
|
-
/>
|
|
547
|
-
))}
|
|
548
|
-
</div>
|
|
549
|
-
</div>
|
|
550
|
-
)
|
|
551
|
-
}
|
|
552
|
-
|
|
553
|
-
function OptionCard({
|
|
554
|
-
active,
|
|
555
|
-
detail,
|
|
556
|
-
icon: Icon,
|
|
557
|
-
name,
|
|
558
|
-
onSelect,
|
|
559
|
-
}: {
|
|
560
|
-
active: boolean
|
|
561
|
-
detail: string
|
|
562
|
-
icon: typeof Sun
|
|
563
|
-
name: string
|
|
564
|
-
onSelect: () => void
|
|
565
|
-
}) {
|
|
566
|
-
return (
|
|
567
|
-
<button
|
|
568
|
-
aria-pressed={active}
|
|
569
|
-
className={cn(
|
|
570
|
-
'flex flex-col items-center gap-0.5 rounded-xl border px-1 py-2 text-center transition hover:bg-accent',
|
|
571
|
-
active
|
|
572
|
-
? 'border-violet-500 bg-violet-500/10 text-violet-700 dark:text-violet-300'
|
|
573
|
-
: 'border-border text-foreground',
|
|
574
|
-
)}
|
|
575
|
-
onClick={onSelect}
|
|
576
|
-
type="button"
|
|
577
|
-
>
|
|
578
|
-
<Icon className={cn('mb-0.5 h-4 w-4', active ? '' : 'text-amber-600 dark:text-amber-400')} />
|
|
579
|
-
<span className="text-xs leading-tight">{name}</span>
|
|
580
|
-
<span className="font-mono text-[11px] text-muted-foreground tabular-nums">{detail}</span>
|
|
581
|
-
</button>
|
|
582
|
-
)
|
|
583
|
-
}
|
|
584
|
-
|
|
585
|
-
// The arc: day above the horizon from sunrise (left) to sunset (right), night dotted below.
|
|
586
|
-
const W = 320
|
|
587
|
-
const H = 150
|
|
588
|
-
const CX = W / 2
|
|
589
|
-
const CY = 100
|
|
590
|
-
const RX = 136
|
|
591
|
-
const RY_DAY = 80
|
|
592
|
-
const RY_NIGHT = 30
|
|
593
|
-
const SNAP = 5
|
|
594
|
-
|
|
595
|
-
function arcPoint(minutes: number, sunrise: number, sunset: number) {
|
|
596
|
-
const dayLength = sunset - sunrise
|
|
597
|
-
if (minutes >= sunrise && minutes <= sunset) {
|
|
598
|
-
const angle = Math.PI * (1 - (minutes - sunrise) / dayLength)
|
|
599
|
-
return { x: CX + RX * Math.cos(angle), y: CY - RY_DAY * Math.sin(angle), day: true }
|
|
600
|
-
}
|
|
601
|
-
const since = (minutes < sunrise ? minutes + 1440 : minutes) - sunset
|
|
602
|
-
const angle = Math.PI * (since / (1440 - dayLength))
|
|
603
|
-
return { x: CX + RX * Math.cos(angle), y: CY + RY_NIGHT * Math.sin(angle), day: false }
|
|
604
|
-
}
|
|
605
|
-
|
|
606
|
-
function arcMinutes(x: number, y: number, sunrise: number, sunset: number) {
|
|
607
|
-
const dayLength = sunset - sunrise
|
|
608
|
-
const angle = Math.atan2((CY - y) / (y < CY ? RY_DAY : RY_NIGHT), (x - CX) / RX)
|
|
609
|
-
const minutes =
|
|
610
|
-
angle >= 0
|
|
611
|
-
? sunrise + (1 - angle / Math.PI) * dayLength
|
|
612
|
-
: sunset + (-angle / Math.PI) * (1440 - dayLength)
|
|
613
|
-
return (((Math.round(minutes / SNAP) * SNAP) % 1440) + 1440) % 1440
|
|
614
|
-
}
|
|
615
|
-
|
|
616
|
-
/** The sun's course from `from` to `to` minutes after sunrise, as an SVG path. */
|
|
617
|
-
function coursePath(sunrise: number, sunset: number, from: number, to: number) {
|
|
618
|
-
if (to - from < 1) return ''
|
|
619
|
-
const steps = Math.max(2, Math.ceil((to - from) / 6))
|
|
620
|
-
let d = ''
|
|
621
|
-
for (let i = 0; i <= steps; i++) {
|
|
622
|
-
const point = arcPoint((sunrise + from + ((to - from) * i) / steps) % 1440, sunrise, sunset)
|
|
623
|
-
d += `${i ? 'L' : 'M'} ${point.x.toFixed(1)} ${point.y.toFixed(1)} `
|
|
624
|
-
}
|
|
625
|
-
return d
|
|
626
|
-
}
|
|
627
|
-
|
|
628
|
-
function SunArc({ sky }: { sky: ReturnType<typeof useSky> }) {
|
|
629
|
-
const i18n = usePascalI18n()
|
|
630
|
-
const slider = useRef<HTMLDivElement | null>(null)
|
|
631
|
-
const { sunrise, sunset } = sky.events
|
|
632
|
-
const sun = arcPoint(sky.minutes, sunrise, sunset)
|
|
633
|
-
const [top, horizon] = PHASE_SKY[sky.phase]
|
|
634
|
-
// The day's course from sunrise: drawn solid as far as the sun, dotted for what is left.
|
|
635
|
-
const elapsed = (((sky.minutes - sunrise) % 1440) + 1440) % 1440
|
|
636
|
-
const travelled = coursePath(sunrise, sunset, 0, elapsed)
|
|
637
|
-
const ahead = coursePath(sunrise, sunset, elapsed, 1440)
|
|
638
|
-
|
|
639
|
-
const setFromPointer = (event: PointerEvent<HTMLDivElement>) => {
|
|
640
|
-
const rect = slider.current?.getBoundingClientRect()
|
|
641
|
-
if (!rect) return
|
|
642
|
-
const x = ((event.clientX - rect.left) / rect.width) * W
|
|
643
|
-
const y = ((event.clientY - rect.top) / rect.height) * H
|
|
644
|
-
stopTween()
|
|
645
|
-
useViewer.getState().setTimeOfDayMinutes(arcMinutes(x, y, sunrise, sunset))
|
|
646
|
-
}
|
|
647
|
-
|
|
648
|
-
return (
|
|
649
|
-
<div
|
|
650
|
-
aria-label={i18n._({
|
|
651
|
-
id: 'viewer.daylight.sky.arc',
|
|
652
|
-
message: 'Drag the sun to set the time',
|
|
653
|
-
})}
|
|
654
|
-
aria-valuemax={1440}
|
|
655
|
-
aria-valuemin={0}
|
|
656
|
-
aria-valuenow={Math.round(sky.minutes)}
|
|
657
|
-
aria-valuetext={formatMinutes(sky.clock)}
|
|
658
|
-
className="cursor-grab touch-none select-none overflow-hidden rounded-xl outline-none focus-visible:ring-2 focus-visible:ring-ring active:cursor-grabbing"
|
|
659
|
-
onKeyDown={(event) => {
|
|
660
|
-
const step = event.key === 'ArrowRight' ? 15 : event.key === 'ArrowLeft' ? -15 : 0
|
|
661
|
-
if (!step) return
|
|
662
|
-
event.preventDefault()
|
|
663
|
-
stopTween()
|
|
664
|
-
useViewer.getState().setTimeOfDayMinutes((sky.minutes + step + 1440) % 1440)
|
|
665
|
-
}}
|
|
666
|
-
onPointerDown={(event) => {
|
|
667
|
-
event.currentTarget.setPointerCapture(event.pointerId)
|
|
668
|
-
setFromPointer(event)
|
|
669
|
-
}}
|
|
670
|
-
onPointerMove={(event) => {
|
|
671
|
-
if (event.currentTarget.hasPointerCapture(event.pointerId)) setFromPointer(event)
|
|
672
|
-
}}
|
|
673
|
-
ref={slider}
|
|
674
|
-
role="slider"
|
|
675
|
-
tabIndex={0}
|
|
676
|
-
>
|
|
677
|
-
<svg aria-hidden="true" className="block w-full" viewBox={`0 0 ${W} ${H}`}>
|
|
678
|
-
<defs>
|
|
679
|
-
<linearGradient id="sky-arc-backdrop" x1="0" x2="0" y1="0" y2="1">
|
|
680
|
-
<stop offset="0" stopColor={top} />
|
|
681
|
-
<stop offset={CY / H} stopColor={horizon} />
|
|
682
|
-
<stop offset={CY / H} stopColor="#9a9aa6" />
|
|
683
|
-
<stop offset="1" stopColor="#6c6c7a" />
|
|
684
|
-
</linearGradient>
|
|
685
|
-
</defs>
|
|
686
|
-
<rect fill="url(#sky-arc-backdrop)" height={H} width={W} />
|
|
687
|
-
<line stroke="rgba(0,0,0,0.25)" x1="0" x2={W} y1={CY} y2={CY} />
|
|
688
|
-
<path d={travelled} fill="none" stroke="#252a44" strokeLinecap="round" strokeWidth="2" />
|
|
689
|
-
<path
|
|
690
|
-
d={ahead}
|
|
691
|
-
fill="none"
|
|
692
|
-
stroke="#252a44"
|
|
693
|
-
strokeDasharray="2 5"
|
|
694
|
-
strokeLinecap="round"
|
|
695
|
-
strokeOpacity="0.7"
|
|
696
|
-
strokeWidth="1.5"
|
|
697
|
-
/>
|
|
698
|
-
{sun.day ? <circle cx={sun.x} cy={sun.y} fill="#fcd165" opacity="0.3" r="15" /> : null}
|
|
699
|
-
<circle
|
|
700
|
-
cx={sun.x}
|
|
701
|
-
cy={sun.y}
|
|
702
|
-
fill={sun.day ? '#fcd165' : '#d6dcf0'}
|
|
703
|
-
r="9"
|
|
704
|
-
stroke="#ffffff"
|
|
705
|
-
strokeWidth="2.5"
|
|
706
|
-
/>
|
|
707
|
-
</svg>
|
|
708
|
-
</div>
|
|
709
|
-
)
|
|
710
|
-
}
|