AmsBackupModal.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329
  1. /**
  2. * #1762 — AMS Filament Backup status modal.
  3. *
  4. * Opens from the AmsBackupBadge click. Shows the global toggle and a
  5. * BambuStudio-style ring graphic per backup pair — each ring represents
  6. * the rotation order the firmware will follow when the active slot runs
  7. * out.
  8. *
  9. * On dual-extruder printers, each ring carries a small "R" / "L" badge
  10. * because the firmware can't cross extruders even with the global backup
  11. * bit set.
  12. *
  13. * Theme-aware via CSS variables, matching AMSHistoryModal — adapts to
  14. * every background variant the user has picked.
  15. */
  16. import { useEffect } from 'react';
  17. import { X } from 'lucide-react';
  18. import { useTranslation } from 'react-i18next';
  19. import { Toggle } from './Toggle';
  20. import {
  21. computeBackupGroups,
  22. isAmsHtUnit,
  23. normalizeColor,
  24. type AmsUnitLike,
  25. type BackupGroup,
  26. } from '../utils/amsHelpers';
  27. interface AmsBackupModalProps {
  28. isOpen: boolean;
  29. state: boolean | null;
  30. amsUnits: AmsUnitLike[] | undefined;
  31. amsExtruderMap: Record<string, number> | undefined;
  32. isDualNozzle: boolean;
  33. canToggle: boolean;
  34. pending: boolean;
  35. onToggle: (next: boolean) => void;
  36. onClose: () => void;
  37. }
  38. /**
  39. * Compact slot label like "A·3" / "HT·1" — the ring is small, every char
  40. * counts toward readability.
  41. */
  42. function formatSlotLabel(amsId: number, slotIdx: number): string {
  43. const isHt = isAmsHtUnit(amsId);
  44. const normalizedId = amsId >= 128 ? amsId - 128 : amsId;
  45. const letter = String.fromCharCode(65 + normalizedId);
  46. return isHt ? `HT·${slotIdx + 1}` : `${letter}·${slotIdx + 1}`;
  47. }
  48. /** Pick a readable text colour for a given filament hex. */
  49. function pickContrastTextColor(rgbaHex: string | null | undefined): string {
  50. const s = (rgbaHex || '').replace('#', '').slice(0, 6);
  51. if (s.length !== 6) return '#FFFFFF';
  52. const r = parseInt(s.slice(0, 2), 16);
  53. const g = parseInt(s.slice(2, 4), 16);
  54. const b = parseInt(s.slice(4, 6), 16);
  55. if ([r, g, b].some(Number.isNaN)) return '#FFFFFF';
  56. const luma = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
  57. return luma > 0.55 ? '#1A1A1A' : '#FFFFFF';
  58. }
  59. function BackupRing({
  60. group,
  61. innerBg,
  62. textPrimary,
  63. textSecondary,
  64. showExtruderBadge,
  65. extruderLabel,
  66. }: {
  67. group: BackupGroup;
  68. innerBg: string;
  69. textPrimary: string;
  70. textSecondary: string;
  71. showExtruderBadge: boolean;
  72. extruderLabel: string;
  73. }) {
  74. const filamentHex = normalizeColor(group.trayColor || undefined);
  75. const ringTextColor = pickContrastTextColor(group.trayColor);
  76. // The pill background that sits behind each slot label — keeps text legible
  77. // regardless of the filament fill colour.
  78. const labelPillBg = ringTextColor === '#FFFFFF' ? 'rgba(0,0,0,0.45)' : 'rgba(255,255,255,0.7)';
  79. const n = group.members.length;
  80. // Geometry: -100..100 viewport. Outer ring 92, inner cutout 56.
  81. // Slot labels sit on the colour band at radius 76.
  82. const labelRadius = 76;
  83. return (
  84. <div className="relative flex flex-col items-center">
  85. {showExtruderBadge && (
  86. <span
  87. className="absolute -top-1 -left-1 z-10 w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold shadow"
  88. style={{
  89. backgroundColor: textPrimary,
  90. color: innerBg,
  91. }}
  92. aria-label={extruderLabel}
  93. title={extruderLabel}
  94. >
  95. {extruderLabel}
  96. </span>
  97. )}
  98. <svg viewBox="-100 -100 200 200" className="w-44 h-44">
  99. {/* Subtle outer ring — gives a crisp edge on light AND dark themes. */}
  100. <circle cx="0" cy="0" r="95" fill="none" stroke={textSecondary} strokeOpacity="0.25" strokeWidth="1" />
  101. {/* Colour band */}
  102. <circle cx="0" cy="0" r="92" fill={filamentHex} />
  103. {/* Inner cutout */}
  104. <circle cx="0" cy="0" r="56" fill={innerBg} />
  105. {/* Inner ring border for definition between centre and colour band */}
  106. <circle cx="0" cy="0" r="56" fill="none" stroke={textSecondary} strokeOpacity="0.3" strokeWidth="1" />
  107. {/* Centre: material name */}
  108. <text
  109. x="0"
  110. y="-4"
  111. textAnchor="middle"
  112. dominantBaseline="middle"
  113. fontSize="14"
  114. fontWeight="700"
  115. fill={textPrimary}
  116. >
  117. {group.displayName || '—'}
  118. </text>
  119. {/* Centre: rotation count */}
  120. <text
  121. x="0"
  122. y="16"
  123. textAnchor="middle"
  124. dominantBaseline="middle"
  125. fontSize="11"
  126. fontWeight="500"
  127. fill={textSecondary}
  128. >
  129. {`${n}× ↻`}
  130. </text>
  131. {/* Slot labels around the ring, each on a pill for legibility */}
  132. {group.members.map((m, i) => {
  133. const angleDeg = (i * 360) / n - 90;
  134. const rad = (angleDeg * Math.PI) / 180;
  135. const x = labelRadius * Math.cos(rad);
  136. const y = labelRadius * Math.sin(rad);
  137. const label = formatSlotLabel(m.amsId, m.slotIdx);
  138. // Approximate pill width based on char count (each digit ≈ 6.5 px @ 12 px font).
  139. const pillWidth = Math.max(22, label.length * 7 + 8);
  140. return (
  141. <g key={`${m.amsId}-${m.slotIdx}`}>
  142. <rect
  143. x={x - pillWidth / 2}
  144. y={y - 9}
  145. width={pillWidth}
  146. height={18}
  147. rx={9}
  148. ry={9}
  149. fill={labelPillBg}
  150. />
  151. <text
  152. x={x}
  153. y={y}
  154. textAnchor="middle"
  155. dominantBaseline="middle"
  156. fontSize="12"
  157. fontWeight="700"
  158. fill={ringTextColor}
  159. >
  160. {label}
  161. </text>
  162. </g>
  163. );
  164. })}
  165. </svg>
  166. </div>
  167. );
  168. }
  169. export function AmsBackupModal({
  170. isOpen,
  171. state,
  172. amsUnits,
  173. amsExtruderMap,
  174. isDualNozzle,
  175. canToggle,
  176. pending,
  177. onToggle,
  178. onClose,
  179. }: AmsBackupModalProps) {
  180. const { t } = useTranslation();
  181. // Close on Escape key while the modal is open. Captures at the window
  182. // level so it works even when focus isn't inside the modal subtree
  183. // (e.g. after the Toggle is clicked).
  184. useEffect(() => {
  185. if (!isOpen) return;
  186. const onKey = (e: KeyboardEvent) => {
  187. if (e.key === 'Escape') {
  188. e.stopPropagation();
  189. onClose();
  190. }
  191. };
  192. window.addEventListener('keydown', onKey);
  193. return () => window.removeEventListener('keydown', onKey);
  194. }, [isOpen, onClose]);
  195. if (!isOpen) return null;
  196. // Theme-aware tokens, matching AMSHistoryModal.
  197. const modalBg = 'var(--bg-secondary)';
  198. const sectionBg = 'var(--bg-primary)';
  199. const borderColor = 'var(--border-color)';
  200. const textPrimary = 'var(--text-primary)';
  201. const textSecondary = 'var(--text-secondary)';
  202. // Effective dual-nozzle detection: only split per extruder if the map
  203. // actually carries 2 distinct values across the AMS units we have data
  204. // for. Empty / single-value maps collapse to a single section to avoid
  205. // misleading badges.
  206. const effectiveDualNozzle = (() => {
  207. if (!isDualNozzle) return false;
  208. if (!amsExtruderMap) return false;
  209. const distinctValues = new Set<number>();
  210. for (const ams of amsUnits || []) {
  211. const raw = amsExtruderMap[String(ams.id)];
  212. if (raw === undefined) continue;
  213. distinctValues.add(Number(raw));
  214. if (distinctValues.size > 1) return true;
  215. }
  216. return false;
  217. })();
  218. const groups = computeBackupGroups(amsUnits, amsExtruderMap, effectiveDualNozzle);
  219. // Only pairs are rendered — lone slots are deliberately suppressed.
  220. const pairs = groups.filter((g) => g.members.length >= 2);
  221. const isOn = state === true;
  222. const isUnknown = state === null;
  223. return (
  224. <div
  225. className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
  226. onClick={onClose}
  227. data-testid="ams-backup-modal"
  228. >
  229. <div
  230. className="rounded-xl w-full max-w-2xl max-h-[90vh] overflow-hidden shadow-xl flex flex-col"
  231. style={{ backgroundColor: modalBg }}
  232. onClick={(e) => e.stopPropagation()}
  233. role="dialog"
  234. aria-modal="true"
  235. aria-labelledby="ams-backup-modal-title"
  236. >
  237. <div
  238. className="flex items-center justify-between px-5 py-3 border-b"
  239. style={{ borderColor }}
  240. >
  241. <h2
  242. id="ams-backup-modal-title"
  243. className="text-base font-semibold"
  244. style={{ color: textPrimary }}
  245. >
  246. {t('printers.amsBackup.modalTitle')}
  247. </h2>
  248. <button
  249. type="button"
  250. onClick={onClose}
  251. className="p-1 rounded-md transition-colors hover:bg-black/10"
  252. style={{ color: textSecondary }}
  253. aria-label={t('common.close')}
  254. >
  255. <X className="w-5 h-5" />
  256. </button>
  257. </div>
  258. <div
  259. className="flex items-center justify-between px-5 py-3 border-b"
  260. style={{ borderColor, backgroundColor: sectionBg }}
  261. >
  262. <div className="min-w-0 mr-3">
  263. <div className="text-sm font-medium" style={{ color: textPrimary }}>
  264. {isUnknown
  265. ? t('printers.amsBackup.stateUnknown')
  266. : isOn
  267. ? t('printers.amsBackup.stateOn')
  268. : t('printers.amsBackup.stateOff')}
  269. </div>
  270. <p className="text-xs mt-0.5" style={{ color: textSecondary }}>
  271. {t('printers.amsBackup.modalHelp')}
  272. </p>
  273. </div>
  274. <Toggle
  275. checked={isOn}
  276. onChange={onToggle}
  277. disabled={!canToggle || isUnknown || pending}
  278. />
  279. </div>
  280. <div className="flex-1 overflow-y-auto px-5 py-6">
  281. {pairs.length === 0 ? (
  282. <p
  283. className="text-sm text-center py-8"
  284. style={{ color: textSecondary }}
  285. >
  286. {t('printers.amsBackup.modalNoPairs')}
  287. </p>
  288. ) : (
  289. <div className="grid grid-cols-1 sm:grid-cols-2 gap-6 justify-items-center">
  290. {pairs.map((g) => (
  291. <BackupRing
  292. key={g.key}
  293. group={g}
  294. innerBg={modalBg}
  295. textPrimary={textPrimary}
  296. textSecondary={textSecondary}
  297. showExtruderBadge={effectiveDualNozzle}
  298. extruderLabel={
  299. g.extruder === 0
  300. ? t('printers.amsBackup.extruderRightShort')
  301. : t('printers.amsBackup.extruderLeftShort')
  302. }
  303. />
  304. ))}
  305. </div>
  306. )}
  307. </div>
  308. </div>
  309. </div>
  310. );
  311. }