AmsBackupModal.tsx 11 KB

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