@celestia-island/hikari 0.4.4

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.
Files changed (187) hide show
  1. package/README.md +54 -0
  2. package/package.json +63 -0
  3. package/src/DemoApp.vue +400 -0
  4. package/src/components/HkActionBar.tsx +15 -0
  5. package/src/components/HkAlert.scss +121 -0
  6. package/src/components/HkAlert.tsx +85 -0
  7. package/src/components/HkAvatar.tsx +29 -0
  8. package/src/components/HkBadge.scss +117 -0
  9. package/src/components/HkBadge.tsx +50 -0
  10. package/src/components/HkBreadcrumb.tsx +39 -0
  11. package/src/components/HkButton.scss +128 -0
  12. package/src/components/HkButton.tsx +74 -0
  13. package/src/components/HkCard.scss +49 -0
  14. package/src/components/HkCard.tsx +38 -0
  15. package/src/components/HkCheckbox.scss +102 -0
  16. package/src/components/HkCheckbox.tsx +85 -0
  17. package/src/components/HkCollapse.tsx +48 -0
  18. package/src/components/HkColorPicker.scss +148 -0
  19. package/src/components/HkColorPicker.tsx +204 -0
  20. package/src/components/HkConfirmDialog.scss +12 -0
  21. package/src/components/HkConfirmDialog.tsx +75 -0
  22. package/src/components/HkDateTimePicker.scss +361 -0
  23. package/src/components/HkDateTimePicker.tsx +424 -0
  24. package/src/components/HkDivider.scss +85 -0
  25. package/src/components/HkDivider.tsx +51 -0
  26. package/src/components/HkDraggableGrid.scss +132 -0
  27. package/src/components/HkDraggableGrid.tsx +291 -0
  28. package/src/components/HkDraggableList.scss +110 -0
  29. package/src/components/HkDraggableList.tsx +238 -0
  30. package/src/components/HkDrawer.scss +175 -0
  31. package/src/components/HkDrawer.tsx +171 -0
  32. package/src/components/HkEmptyState.scss +42 -0
  33. package/src/components/HkEmptyState.tsx +41 -0
  34. package/src/components/HkErrorBoundary.scss +64 -0
  35. package/src/components/HkErrorBoundary.tsx +82 -0
  36. package/src/components/HkGaugeRing.scss +4 -0
  37. package/src/components/HkGaugeRing.tsx +130 -0
  38. package/src/components/HkHoverRevealAction.scss +66 -0
  39. package/src/components/HkHoverRevealAction.tsx +77 -0
  40. package/src/components/HkIcon.tsx +43 -0
  41. package/src/components/HkIconButton.tsx +42 -0
  42. package/src/components/HkImageViewer.scss +33 -0
  43. package/src/components/HkImageViewer.tsx +250 -0
  44. package/src/components/HkInput.scss +172 -0
  45. package/src/components/HkInput.tsx +175 -0
  46. package/src/components/HkKbd.tsx +26 -0
  47. package/src/components/HkKeywordSearchModal.scss +148 -0
  48. package/src/components/HkKeywordSearchModal.tsx +308 -0
  49. package/src/components/HkListTransition.scss +131 -0
  50. package/src/components/HkListTransition.tsx +29 -0
  51. package/src/components/HkLocalePickerPopup.tsx +42 -0
  52. package/src/components/HkLogo.scss +36 -0
  53. package/src/components/HkLogo.tsx +93 -0
  54. package/src/components/HkMarkdownRenderer.scss +141 -0
  55. package/src/components/HkMarkdownRenderer.tsx +123 -0
  56. package/src/components/HkMediaControlBar.scss +65 -0
  57. package/src/components/HkMediaControlBar.tsx +115 -0
  58. package/src/components/HkMediaPlayer.scss +29 -0
  59. package/src/components/HkMediaPlayer.tsx +210 -0
  60. package/src/components/HkMediaSlider.scss +54 -0
  61. package/src/components/HkMediaSlider.tsx +81 -0
  62. package/src/components/HkMediaVisualizer.scss +7 -0
  63. package/src/components/HkMediaVisualizer.tsx +119 -0
  64. package/src/components/HkMinimap.scss +76 -0
  65. package/src/components/HkMinimap.tsx +255 -0
  66. package/src/components/HkModal.scss +318 -0
  67. package/src/components/HkModal.tsx +481 -0
  68. package/src/components/HkModalBreadcrumb.scss +55 -0
  69. package/src/components/HkModalBreadcrumb.tsx +113 -0
  70. package/src/components/HkMorphingTabs.scss +141 -0
  71. package/src/components/HkMorphingTabs.tsx +113 -0
  72. package/src/components/HkNavItem.scss +81 -0
  73. package/src/components/HkNavItem.tsx +157 -0
  74. package/src/components/HkNumberInput.scss +154 -0
  75. package/src/components/HkNumberInput.tsx +161 -0
  76. package/src/components/HkPasswordInput.scss +289 -0
  77. package/src/components/HkPasswordInput.tsx +533 -0
  78. package/src/components/HkPhaseTransition.scss +25 -0
  79. package/src/components/HkPhaseTransition.tsx +40 -0
  80. package/src/components/HkPopover.scss +82 -0
  81. package/src/components/HkPopover.tsx +410 -0
  82. package/src/components/HkPopupSelect.scss +136 -0
  83. package/src/components/HkPopupSelect.tsx +199 -0
  84. package/src/components/HkProgressBar.scss +106 -0
  85. package/src/components/HkProgressBar.tsx +61 -0
  86. package/src/components/HkProgressDialog.tsx +67 -0
  87. package/src/components/HkProgressRing.scss +56 -0
  88. package/src/components/HkProgressRing.tsx +82 -0
  89. package/src/components/HkRadio.scss +119 -0
  90. package/src/components/HkRadio.tsx +75 -0
  91. package/src/components/HkRollingNumber.scss +37 -0
  92. package/src/components/HkRollingNumber.tsx +97 -0
  93. package/src/components/HkScrollContainer.scss +101 -0
  94. package/src/components/HkScrollContainer.tsx +362 -0
  95. package/src/components/HkSearchInput.scss +132 -0
  96. package/src/components/HkSearchInput.tsx +117 -0
  97. package/src/components/HkSelect.scss +143 -0
  98. package/src/components/HkSelect.tsx +230 -0
  99. package/src/components/HkSelectionGrid.scss +177 -0
  100. package/src/components/HkSelectionGrid.tsx +99 -0
  101. package/src/components/HkSelectionWaterfall.scss +93 -0
  102. package/src/components/HkSelectionWaterfall.tsx +90 -0
  103. package/src/components/HkSidebar.scss +81 -0
  104. package/src/components/HkSidebar.tsx +45 -0
  105. package/src/components/HkSkeleton.scss +44 -0
  106. package/src/components/HkSkeleton.tsx +30 -0
  107. package/src/components/HkSkeletonList.tsx +35 -0
  108. package/src/components/HkSpinner.scss +67 -0
  109. package/src/components/HkSpinner.tsx +41 -0
  110. package/src/components/HkSplash.scss +60 -0
  111. package/src/components/HkSplash.tsx +40 -0
  112. package/src/components/HkSwitch.scss +157 -0
  113. package/src/components/HkSwitch.tsx +72 -0
  114. package/src/components/HkTable.scss +198 -0
  115. package/src/components/HkTable.tsx +246 -0
  116. package/src/components/HkTabs.scss +109 -0
  117. package/src/components/HkTabs.tsx +111 -0
  118. package/src/components/HkTag.scss +88 -0
  119. package/src/components/HkTag.tsx +44 -0
  120. package/src/components/HkTextarea.scss +72 -0
  121. package/src/components/HkTextarea.tsx +104 -0
  122. package/src/components/HkTimeline.scss +139 -0
  123. package/src/components/HkTimeline.tsx +95 -0
  124. package/src/components/HkToast.scss +168 -0
  125. package/src/components/HkToast.tsx +175 -0
  126. package/src/components/HkTooltip.scss +47 -0
  127. package/src/components/HkTooltip.tsx +108 -0
  128. package/src/components/HkTree.scss +188 -0
  129. package/src/components/HkTree.tsx +217 -0
  130. package/src/components/HkTrendChart.tsx +180 -0
  131. package/src/components/HkWindowedItem.scss +17 -0
  132. package/src/components/HkWindowedItem.tsx +107 -0
  133. package/src/components/HkZoomToolbar.scss +35 -0
  134. package/src/components/HkZoomToolbar.tsx +69 -0
  135. package/src/composables/useBreakpoint.ts +41 -0
  136. package/src/composables/useClipboard.ts +37 -0
  137. package/src/composables/useConfirm.ts +35 -0
  138. package/src/composables/useProgressDialog.ts +52 -0
  139. package/src/composables/useReportedTransition.ts +69 -0
  140. package/src/composables/useScrollWindow.ts +21 -0
  141. package/src/composables/useToast.ts +2 -0
  142. package/src/demo.scss +24 -0
  143. package/src/demo.ts +22 -0
  144. package/src/env.d.ts +6 -0
  145. package/src/i18n/context.ts +89 -0
  146. package/src/i18n/locales/ar/components.json +43 -0
  147. package/src/i18n/locales/ar/components.toml +40 -0
  148. package/src/i18n/locales/de/components.json +43 -0
  149. package/src/i18n/locales/de/components.toml +40 -0
  150. package/src/i18n/locales/en/components.json +43 -0
  151. package/src/i18n/locales/en/components.toml +40 -0
  152. package/src/i18n/locales/es/components.json +43 -0
  153. package/src/i18n/locales/es/components.toml +40 -0
  154. package/src/i18n/locales/fr/components.json +43 -0
  155. package/src/i18n/locales/fr/components.toml +40 -0
  156. package/src/i18n/locales/ja/components.json +43 -0
  157. package/src/i18n/locales/ja/components.toml +40 -0
  158. package/src/i18n/locales/ko/components.json +43 -0
  159. package/src/i18n/locales/ko/components.toml +40 -0
  160. package/src/i18n/locales/pt/components.json +43 -0
  161. package/src/i18n/locales/pt/components.toml +40 -0
  162. package/src/i18n/locales/ru/components.json +43 -0
  163. package/src/i18n/locales/ru/components.toml +40 -0
  164. package/src/i18n/locales/zh-Hans/components.json +43 -0
  165. package/src/i18n/locales/zh-Hans/components.toml +40 -0
  166. package/src/i18n/locales/zh-Hant/components.json +43 -0
  167. package/src/i18n/locales/zh-Hant/components.toml +40 -0
  168. package/src/index.ts +137 -0
  169. package/src/render.ts +18 -0
  170. package/src/runtime/animationBus.ts +278 -0
  171. package/src/runtime/cronBus.ts +25 -0
  172. package/src/runtime/index.ts +12 -0
  173. package/src/runtime/useAsyncData.ts +30 -0
  174. package/src/runtime/useBreakpoint.ts +62 -0
  175. package/src/runtime/useClipboard.ts +37 -0
  176. package/src/runtime/useConfirm.ts +35 -0
  177. package/src/runtime/useOverlay.ts +82 -0
  178. package/src/runtime/usePopupManager.ts +89 -0
  179. package/src/runtime/useToast.ts +140 -0
  180. package/src/styles/index.scss +10 -0
  181. package/src/theme/index.ts +5 -0
  182. package/src/theme/presets.ts +307 -0
  183. package/src/theme/theme.scss +17 -0
  184. package/src/theme/useBackgroundLuminance.ts +145 -0
  185. package/src/theme/useSolarTime.ts +101 -0
  186. package/src/theme/useTheme.ts +141 -0
  187. package/src/tokens.scss +26 -0
@@ -0,0 +1,54 @@
1
+ .hk-media-slider {
2
+ position: relative;
3
+ height: 6px;
4
+ border-radius: var(--radius-sm);
5
+ background: rgb(var(--color-muted) / 22%);
6
+ cursor: pointer;
7
+ touch-action: none;
8
+ user-select: none;
9
+ }
10
+
11
+ .hk-media-slider[data-size="sm"] {
12
+ height: 5px;
13
+ }
14
+
15
+ .hk-media-slider.is-disabled {
16
+ cursor: default;
17
+ opacity: 0.5;
18
+ }
19
+
20
+ .hk-media-slider-buffered {
21
+ position: absolute;
22
+ inset: 0 auto 0 0;
23
+ height: 100%;
24
+ border-radius: var(--radius-sm);
25
+ background: rgb(var(--color-muted) / 40%);
26
+ }
27
+
28
+ .hk-media-slider-fill {
29
+ position: absolute;
30
+ inset: 0 auto 0 0;
31
+ height: 100%;
32
+ border-radius: var(--radius-sm);
33
+ background: rgb(var(--color-primary));
34
+ }
35
+
36
+ .hk-media-slider-thumb {
37
+ position: absolute;
38
+ top: 50%;
39
+ width: 12px;
40
+ height: 12px;
41
+ margin-left: -6px;
42
+ border-radius: 50%;
43
+ background: rgb(var(--color-primary));
44
+ box-shadow: 0 1px 3px rgb(0 0 0 / 35%);
45
+ transform: translateY(-50%);
46
+ opacity: 0;
47
+ transition: opacity var(--hi-duration-short, 0.15s);
48
+ pointer-events: none;
49
+ }
50
+
51
+ .hk-media-slider:hover .hk-media-slider-thumb,
52
+ .hk-media-slider:active .hk-media-slider-thumb {
53
+ opacity: 1;
54
+ }
@@ -0,0 +1,81 @@
1
+ import { defineComponent, onBeforeUnmount, onMounted, ref } from "vue";
2
+
3
+ import "./HkMediaSlider.scss";
4
+
5
+ /**
6
+ * Generic draggable 0..1 track — the one primitive both the seek bar and the
7
+ * volume slider are built on. Pointer-drag (with capture) emits the ratio
8
+ * continuously so consumers can scrub live. An optional secondary `buffered`
9
+ * fill renders behind the played fill.
10
+ */
11
+ export default defineComponent({
12
+ name: "HkMediaSlider",
13
+ props: {
14
+ ratio: { type: Number, required: true },
15
+ buffered: { type: Number, default: 0 },
16
+ disabled: { type: Boolean, default: false },
17
+ ariaLabel: { type: String, default: undefined },
18
+ size: { type: String as () => "md" | "sm", default: "md" },
19
+ },
20
+ emits: {
21
+ "update:ratio": (_ratio: number) => true,
22
+ },
23
+ setup(props, { emit }) {
24
+ const trackRef = ref<HTMLElement | null>(null);
25
+ let dragging = false;
26
+
27
+ function ratioFrom(clientX: number): number {
28
+ const el = trackRef.value;
29
+ if (!el) return 0;
30
+ const rect = el.getBoundingClientRect();
31
+ return Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
32
+ }
33
+ function onDown(e: PointerEvent) {
34
+ if (props.disabled) return;
35
+ dragging = true;
36
+ (e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId);
37
+ emit("update:ratio", ratioFrom(e.clientX));
38
+ }
39
+ function onMove(e: PointerEvent) {
40
+ if (dragging) emit("update:ratio", ratioFrom(e.clientX));
41
+ }
42
+ function onUp() {
43
+ dragging = false;
44
+ }
45
+
46
+ onMounted(() => {
47
+ window.addEventListener("pointermove", onMove);
48
+ window.addEventListener("pointerup", onUp);
49
+ window.addEventListener("pointercancel", onUp);
50
+ });
51
+ onBeforeUnmount(() => {
52
+ window.removeEventListener("pointermove", onMove);
53
+ window.removeEventListener("pointerup", onUp);
54
+ window.removeEventListener("pointercancel", onUp);
55
+ });
56
+
57
+ return () => (
58
+ <div
59
+ ref={trackRef}
60
+ class={[
61
+ "hk-media-slider",
62
+ props.disabled && "is-disabled",
63
+ ].filter(Boolean).join(" ")}
64
+ data-size={props.size}
65
+ role="slider"
66
+ tabindex={-1}
67
+ aria-label={props.ariaLabel}
68
+ aria-valuenow={Math.round(props.ratio * 100)}
69
+ aria-valuemin={0}
70
+ aria-valuemax={100}
71
+ onPointerdown={onDown}
72
+ >
73
+ {props.buffered > 0 && (
74
+ <div class="hk-media-slider-buffered" style={{ width: `${props.buffered * 100}%` }} />
75
+ )}
76
+ <div class="hk-media-slider-fill" style={{ width: `${props.ratio * 100}%` }} />
77
+ <div class="hk-media-slider-thumb" style={{ left: `${props.ratio * 100}%` }} />
78
+ </div>
79
+ );
80
+ },
81
+ });
@@ -0,0 +1,7 @@
1
+ .hk-media-visualizer {
2
+ width: 100%;
3
+ height: 8rem;
4
+ display: block;
5
+ background: rgb(var(--color-muted) / 6%);
6
+ border-radius: var(--radius-sm);
7
+ }
@@ -0,0 +1,119 @@
1
+ import { defineComponent, onBeforeUnmount, ref, watch, type PropType, type Ref } from "vue";
2
+
3
+ import { onFrame, type AnimationHandle } from "../runtime/animationBus";
4
+ import "./HkMediaVisualizer.scss";
5
+
6
+ /**
7
+ * Pluggable audio spectrum visualiser. Point it at any media element ref and
8
+ * it draws an analyser-driven frequency bar chart while that element plays
9
+ * (and stops otherwise). Self-contained WebAudio wiring — the
10
+ * `MediaElementAudioSourceNode` is created lazily and only once per element.
11
+ *
12
+ * The draw loop registers with the unified animation bus via `onFrame`
13
+ * (sync priority) rather than a private rAF, so it shares the same loop
14
+ * lifecycle as every other per-frame consumer and the bus's
15
+ * idle-auto-shutdown applies to it too.
16
+ */
17
+ export default defineComponent({
18
+ name: "HkMediaVisualizer",
19
+ props: {
20
+ mediaRef: {
21
+ type: Object as PropType<Ref<HTMLMediaElement | null>>,
22
+ required: true,
23
+ },
24
+ },
25
+ setup(props) {
26
+ const canvasRef = ref<HTMLCanvasElement | null>(null);
27
+ let audioCtx: AudioContext | null = null;
28
+ let analyser: AnalyserNode | null = null;
29
+ let source: MediaElementAudioSourceNode | null = null;
30
+ let frameHandle: AnimationHandle | null = null;
31
+
32
+ function start() {
33
+ const media = props.mediaRef.value;
34
+ const canvas = canvasRef.value;
35
+ if (!media || !canvas) return;
36
+ try {
37
+ if (!audioCtx) {
38
+ audioCtx = new (window.AudioContext ||
39
+ (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext)();
40
+ analyser = audioCtx.createAnalyser();
41
+ analyser.fftSize = 128;
42
+ source = audioCtx.createMediaElementSource(media);
43
+ source.connect(analyser);
44
+ analyser.connect(audioCtx.destination);
45
+ }
46
+ if (audioCtx.state === "suspended") void audioCtx.resume();
47
+ } catch {
48
+ return;
49
+ }
50
+ const ctx2d = canvas.getContext("2d");
51
+ if (!ctx2d || !analyser) return;
52
+ canvas.width = canvas.clientWidth * 2;
53
+ canvas.height = canvas.clientHeight * 2;
54
+ const data = new Uint8Array(analyser.frequencyBinCount);
55
+
56
+ function draw() {
57
+ if (!analyser || !ctx2d || !canvas) return;
58
+ analyser.getByteFrequencyData(data);
59
+ const w = canvas.width;
60
+ const h = canvas.height;
61
+ ctx2d.clearRect(0, 0, w, h);
62
+ const n = data.length;
63
+ const bw = w / n;
64
+ for (let i = 0; i < n; i++) {
65
+ const v = data[i] / 255;
66
+ const bh = v * h * 0.85;
67
+ const hue = 200 + (i / n) * 80;
68
+ ctx2d.fillStyle = `hsl(${hue}, 70%, 55%)`;
69
+ ctx2d.fillRect(i * bw, h - bh, bw - 1, bh);
70
+ }
71
+ // No self-reschedule — onFrame keeps firing until disconnect().
72
+ }
73
+
74
+ // Drop any prior handle (e.g. a replay after pause) before subscribing.
75
+ if (frameHandle) {
76
+ frameHandle.disconnect();
77
+ frameHandle = null;
78
+ }
79
+ frameHandle = onFrame(draw, "sync");
80
+ }
81
+
82
+ function stop() {
83
+ if (frameHandle) {
84
+ frameHandle.disconnect();
85
+ frameHandle = null;
86
+ }
87
+ }
88
+
89
+ let cleanupListeners: (() => void) | null = null;
90
+ function attach(media: HTMLMediaElement | null) {
91
+ cleanupListeners?.();
92
+ cleanupListeners = null;
93
+ if (!media) return;
94
+ media.addEventListener("play", start);
95
+ media.addEventListener("pause", stop);
96
+ media.addEventListener("ended", stop);
97
+ cleanupListeners = () => {
98
+ media.removeEventListener("play", start);
99
+ media.removeEventListener("pause", stop);
100
+ media.removeEventListener("ended", stop);
101
+ };
102
+ }
103
+
104
+ watch(() => props.mediaRef.value, attach, { immediate: true });
105
+
106
+ onBeforeUnmount(() => {
107
+ stop();
108
+ cleanupListeners?.();
109
+ source?.disconnect();
110
+ analyser?.disconnect();
111
+ audioCtx?.close().catch(() => {});
112
+ audioCtx = null;
113
+ analyser = null;
114
+ source = null;
115
+ });
116
+
117
+ return () => <canvas ref={canvasRef} class="hk-media-visualizer" />;
118
+ },
119
+ });
@@ -0,0 +1,76 @@
1
+ .hk-minimap {
2
+ position: absolute;
3
+ bottom: 12px;
4
+ right: 12px;
5
+ z-index: 50;
6
+ width: 160px;
7
+ background: rgb(var(--color-surface) / 0.82);
8
+ border: 1px solid var(--border-faint);
9
+ border-radius: var(--radius-sm);
10
+ backdrop-filter: blur(var(--blur-md));
11
+ cursor: pointer;
12
+ overflow: hidden;
13
+ pointer-events: auto;
14
+
15
+ &:hover {
16
+ border-color: rgb(var(--color-primary) / 0.3);
17
+ }
18
+
19
+ &[data-dragging] {
20
+ cursor: grabbing;
21
+ }
22
+ }
23
+
24
+ .hk-minimap-svg {
25
+ display: block;
26
+ width: 100%;
27
+ height: auto;
28
+ }
29
+
30
+ .hk-mm-zoom-bar {
31
+ display: flex;
32
+ align-items: center;
33
+ justify-content: center;
34
+ gap: 2px;
35
+ padding: 3px 8px;
36
+ border-top: 1px solid var(--border-faint);
37
+ pointer-events: auto;
38
+ }
39
+
40
+ .hk-mm-zoom-btn {
41
+ display: flex;
42
+ align-items: center;
43
+ justify-content: center;
44
+ width: 22px;
45
+ height: 22px;
46
+ border: none;
47
+ border-radius: var(--radius-sm);
48
+ background: transparent;
49
+ color: rgb(var(--color-text));
50
+ cursor: pointer;
51
+ transition: background 0.1s ease, color 0.1s ease;
52
+
53
+ &:hover:not(:disabled) {
54
+ background: rgb(var(--color-primary) / 0.1);
55
+ color: rgb(var(--color-primary));
56
+ }
57
+
58
+ &:disabled {
59
+ color: rgb(var(--color-muted) / 40%);
60
+ cursor: not-allowed;
61
+ }
62
+ }
63
+
64
+ .hk-mm-zoom-reset-btn svg {
65
+ stroke-width: 2px;
66
+ }
67
+
68
+ .hk-mm-zoom-label {
69
+ font-size: var(--text-2xs);
70
+ font-weight: 600;
71
+ font-variant-numeric: tabular-nums;
72
+ color: rgb(var(--color-muted));
73
+ min-width: 30px;
74
+ text-align: center;
75
+ user-select: none;
76
+ }
@@ -0,0 +1,255 @@
1
+ import { computed, defineComponent, onBeforeUnmount, onMounted, ref, type PropType } from "vue";
2
+ import { Maximize2, ZoomIn, ZoomOut } from "lucide-vue-next";
3
+
4
+ import { useI18n } from "../i18n/context";
5
+ import "./HkMinimap.scss";
6
+
7
+ export interface MinimapBox {
8
+ id: string;
9
+ bounds: { x: number; y: number; w: number; h: number };
10
+ color: string;
11
+ }
12
+
13
+ export interface MinimapRect {
14
+ x: number;
15
+ y: number;
16
+ w: number;
17
+ h: number;
18
+ }
19
+
20
+ /**
21
+ * Generic minimap / overview overlay for pannable+zoomable surfaces.
22
+ *
23
+ * Renders an optionally-zoomed content area as an SVG scaled to fit, with
24
+ * coloured boxes (or an image background for an image navigator), a hub
25
+ * marker, the current viewport rectangle, and a small zoom bar. Dragging
26
+ * inside the map pans the viewport via the `panDelta` event. The math
27
+ * assumes the main surface uses `translate(panX, panY) scale(zoom)` with
28
+ * `transform-origin: 0 0` — the same model HImageViewer uses.
29
+ */
30
+ export default defineComponent({
31
+ name: "HkMinimap",
32
+ props: {
33
+ boxes: { type: Array as PropType<MinimapBox[]>, default: () => [] },
34
+ hubPos: { type: Object as PropType<{ x: number; y: number } | null>, default: null },
35
+ /** Optional image rendered as the minimap background (image navigator).
36
+ * When set, the minimap renders even with no boxes. */
37
+ imageSrc: { type: String, default: undefined },
38
+ imageBounds: { type: Object as PropType<MinimapRect>, default: undefined },
39
+ zoom: { type: Number, default: 1 },
40
+ panX: { type: Number, default: 0 },
41
+ panY: { type: Number, default: 0 },
42
+ viewportWidth: { type: Number, default: 800 },
43
+ viewportHeight: { type: Number, default: 600 },
44
+ contentBounds: {
45
+ type: Object as PropType<MinimapRect>,
46
+ default: () => ({ x: 0, y: 0, w: 1200, h: 800 }),
47
+ },
48
+ zoomPercent: { type: Number, default: 100 },
49
+ canZoomIn: { type: Boolean, default: true },
50
+ canZoomOut: { type: Boolean, default: true },
51
+ /** Show the reset/fit button in the zoom bar (chest only rendered it
52
+ * when a reset handler was wired up). */
53
+ showReset: { type: Boolean, default: false },
54
+ },
55
+ emits: {
56
+ zoomIn: () => true,
57
+ zoomOut: () => true,
58
+ reset: () => true,
59
+ panDelta: (_dx: number, _dy: number) => true,
60
+ },
61
+ setup(props, { emit }) {
62
+ const { t } = useI18n();
63
+ const svgW = 160;
64
+ const svgH = 110;
65
+ const rootRef = ref<HTMLElement | null>(null);
66
+ const dragging = ref(false);
67
+ const dragStart = ref({ x: 0, y: 0 });
68
+
69
+ const cb = computed(() => props.contentBounds);
70
+ const overpanW = computed(() => Math.max(props.contentBounds.w * 0.5, props.viewportWidth * 0.3));
71
+ const overpanH = computed(() => Math.max(props.contentBounds.h * 0.5, props.viewportHeight * 0.3));
72
+
73
+ const mapRect = computed(() => ({
74
+ x: cb.value.x - overpanW.value,
75
+ y: cb.value.y - overpanH.value,
76
+ w: cb.value.w + overpanW.value * 2,
77
+ h: cb.value.h + overpanH.value * 2,
78
+ }));
79
+
80
+ const scale = computed(() => {
81
+ const sx = svgW / mapRect.value.w;
82
+ const sy = svgH / mapRect.value.h;
83
+ return Math.min(sx, sy);
84
+ });
85
+
86
+ const contentOffset = computed(() => {
87
+ const sw = mapRect.value.w * scale.value;
88
+ const sh = mapRect.value.h * scale.value;
89
+ return { ox: (svgW - sw) / 2, oy: (svgH - sh) / 2 };
90
+ });
91
+
92
+ function toMap(wx: number, wy: number): [number, number] {
93
+ const s = scale.value;
94
+ const { ox, oy } = contentOffset.value;
95
+ return [(wx - mapRect.value.x) * s + ox, (wy - mapRect.value.y) * s + oy];
96
+ }
97
+
98
+ const viewportRect = computed(() => {
99
+ const z = props.zoom;
100
+ const tl = toMap(-props.panX / z, -props.panY / z);
101
+ const br = toMap(-props.panX / z + props.viewportWidth / z, -props.panY / z + props.viewportHeight / z);
102
+ return { x: tl[0], y: tl[1], w: Math.max(1, br[0] - tl[0]), h: Math.max(1, br[1] - tl[1]) };
103
+ });
104
+
105
+ function onDown(e: PointerEvent) {
106
+ if ((e.target as HTMLElement).closest(".hk-mm-zoom-bar")) return;
107
+ e.stopPropagation();
108
+ e.preventDefault();
109
+ dragging.value = true;
110
+ dragStart.value = { x: e.clientX, y: e.clientY };
111
+ rootRef.value?.setPointerCapture(e.pointerId);
112
+ }
113
+ function onMove(e: PointerEvent) {
114
+ if (!dragging.value) return;
115
+ e.stopPropagation();
116
+ const dx = e.clientX - dragStart.value.x;
117
+ const dy = e.clientY - dragStart.value.y;
118
+ dragStart.value = { x: e.clientX, y: e.clientY };
119
+ if (scale.value > 0) {
120
+ emit("panDelta", (-dx / scale.value) * props.zoom, (-dy / scale.value) * props.zoom);
121
+ }
122
+ }
123
+ function onUp(e: PointerEvent) {
124
+ if (!dragging.value) return;
125
+ e.stopPropagation();
126
+ dragging.value = false;
127
+ rootRef.value?.releasePointerCapture(e.pointerId);
128
+ }
129
+
130
+ onMounted(() => {
131
+ const el = rootRef.value;
132
+ if (!el) return;
133
+ el.addEventListener("pointerdown", onDown);
134
+ el.addEventListener("pointermove", onMove);
135
+ el.addEventListener("pointerup", onUp);
136
+ el.addEventListener("pointercancel", onUp);
137
+ });
138
+ onBeforeUnmount(() => {
139
+ const el = rootRef.value;
140
+ if (!el) return;
141
+ el.removeEventListener("pointerdown", onDown);
142
+ el.removeEventListener("pointermove", onMove);
143
+ el.removeEventListener("pointerup", onUp);
144
+ el.removeEventListener("pointercancel", onUp);
145
+ });
146
+
147
+ return () => {
148
+ if (props.boxes.length === 0 && !props.imageSrc) return null;
149
+
150
+ const s = scale.value;
151
+ const vr = viewportRect.value;
152
+
153
+ const ib = props.imageBounds ?? props.contentBounds;
154
+ const imgPos = props.imageSrc ? toMap(ib.x, ib.y) : null;
155
+
156
+ const boxRects = props.boxes.map((b) => {
157
+ const p = toMap(b.bounds.x, b.bounds.y);
158
+ return (
159
+ <rect
160
+ key={`mm-${b.id}`}
161
+ x={p[0]}
162
+ y={p[1]}
163
+ width={b.bounds.w * s}
164
+ height={b.bounds.h * s}
165
+ rx={2}
166
+ fill={b.color}
167
+ opacity="0.28"
168
+ stroke={b.color}
169
+ stroke-width="0.6"
170
+ />
171
+ );
172
+ });
173
+
174
+ const hubP = props.hubPos ? toMap(props.hubPos.x, props.hubPos.y) : null;
175
+
176
+ return (
177
+ <div
178
+ ref={rootRef}
179
+ class="hk-minimap"
180
+ data-dragging={dragging.value ? "" : undefined}
181
+ >
182
+ <svg viewBox={`0 0 ${svgW} ${svgH}`} width={svgW} height={svgH} class="hk-minimap-svg">
183
+ {imgPos && (
184
+ <image
185
+ href={props.imageSrc}
186
+ x={imgPos[0]}
187
+ y={imgPos[1]}
188
+ width={ib.w * s}
189
+ height={ib.h * s}
190
+ preserveAspectRatio="none"
191
+ class="hk-mm-image"
192
+ />
193
+ )}
194
+ {boxRects}
195
+ {hubP && (
196
+ <circle
197
+ cx={hubP[0]}
198
+ cy={hubP[1]}
199
+ r={3}
200
+ fill="rgb(var(--color-primary))"
201
+ filter="drop-shadow(0 0 2px rgb(var(--color-primary) / 0.6))"
202
+ />
203
+ )}
204
+ <rect
205
+ x={vr.x}
206
+ y={vr.y}
207
+ width={Math.max(1, vr.w)}
208
+ height={Math.max(1, vr.h)}
209
+ fill="none"
210
+ stroke="rgb(var(--color-primary))"
211
+ stroke-width="1"
212
+ stroke-dasharray="3 2"
213
+ rx="2"
214
+ opacity="0.85"
215
+ />
216
+ </svg>
217
+ <div class="hk-mm-zoom-bar">
218
+ <button
219
+ class="hk-mm-zoom-btn"
220
+ type="button"
221
+ onClick={() => emit("zoomOut")}
222
+ disabled={!props.canZoomOut}
223
+ aria-label={t("hk.zoomToolbar.zoomOut", "Zoom out")}
224
+ title={t("hk.zoomToolbar.zoomOut", "Zoom out")}
225
+ >
226
+ <ZoomOut size={12} />
227
+ </button>
228
+ <span class="hk-mm-zoom-label">{props.zoomPercent}%</span>
229
+ <button
230
+ class="hk-mm-zoom-btn"
231
+ type="button"
232
+ onClick={() => emit("zoomIn")}
233
+ disabled={!props.canZoomIn}
234
+ aria-label={t("hk.zoomToolbar.zoomIn", "Zoom in")}
235
+ title={t("hk.zoomToolbar.zoomIn", "Zoom in")}
236
+ >
237
+ <ZoomIn size={12} />
238
+ </button>
239
+ {props.showReset && (
240
+ <button
241
+ class="hk-mm-zoom-btn hk-mm-zoom-reset-btn"
242
+ type="button"
243
+ onClick={() => emit("reset")}
244
+ aria-label={t("hk.zoomToolbar.reset", "Reset zoom")}
245
+ title={t("hk.zoomToolbar.reset", "Reset zoom")}
246
+ >
247
+ <Maximize2 size={11} />
248
+ </button>
249
+ )}
250
+ </div>
251
+ </div>
252
+ );
253
+ };
254
+ },
255
+ });