AMSSectionDual.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337
  1. import { useState } from 'react';
  2. import { useMutation } from '@tanstack/react-query';
  3. import { api } from '../../api/client';
  4. import type { PrinterStatus, AMSUnit } from '../../api/client';
  5. import { Loader2 } from 'lucide-react';
  6. interface AMSSectionDualProps {
  7. printerId: number;
  8. status: PrinterStatus | null | undefined;
  9. nozzleCount: number;
  10. }
  11. function hexToRgb(hex: string | null): string {
  12. if (!hex) return 'rgb(128, 128, 128)';
  13. const cleanHex = hex.replace('#', '').substring(0, 6);
  14. const r = parseInt(cleanHex.substring(0, 2), 16) || 128;
  15. const g = parseInt(cleanHex.substring(2, 4), 16) || 128;
  16. const b = parseInt(cleanHex.substring(4, 6), 16) || 128;
  17. return `rgb(${r}, ${g}, ${b})`;
  18. }
  19. function isLightColor(hex: string | null): boolean {
  20. if (!hex) return false;
  21. const cleanHex = hex.replace('#', '').substring(0, 6);
  22. const r = parseInt(cleanHex.substring(0, 2), 16) || 0;
  23. const g = parseInt(cleanHex.substring(2, 4), 16) || 0;
  24. const b = parseInt(cleanHex.substring(4, 6), 16) || 0;
  25. const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
  26. return luminance > 0.5;
  27. }
  28. interface AMSPanelContentProps {
  29. units: AMSUnit[];
  30. side: 'left' | 'right';
  31. isPrinting: boolean;
  32. selectedAmsIndex: number;
  33. onSelectAms: (index: number) => void;
  34. selectedTray: number | null;
  35. onSelectTray: (trayId: number | null) => void;
  36. }
  37. function AMSPanelContent({
  38. units,
  39. side,
  40. isPrinting,
  41. selectedAmsIndex,
  42. onSelectAms,
  43. selectedTray,
  44. onSelectTray,
  45. }: AMSPanelContentProps) {
  46. const selectedUnit = units[selectedAmsIndex];
  47. const slotPrefix = side === 'left' ? 'A' : 'B';
  48. return (
  49. <div className="flex-1 min-w-0 overflow-visible">
  50. <div className="text-center text-[11px] font-semibold text-bambu-gray uppercase mb-2">
  51. {side === 'left' ? 'Left Nozzle' : 'Right Nozzle'}
  52. </div>
  53. {/* AMS Tab Selectors - only show connected units */}
  54. <div className="flex gap-1.5 mb-2.5 p-1.5 bg-bambu-dark/50 rounded-lg w-fit">
  55. {units.map((unit, index) => (
  56. <button
  57. key={unit.id}
  58. onClick={() => onSelectAms(index)}
  59. className={`flex items-center p-1.5 rounded border-2 transition-colors bg-bambu-dark ${
  60. selectedAmsIndex === index
  61. ? 'border-bambu-green'
  62. : 'border-transparent hover:border-bambu-gray'
  63. }`}
  64. >
  65. <div className="flex gap-0.5">
  66. {unit.tray.map((tray) => (
  67. <div
  68. key={tray.id}
  69. className="w-2.5 h-2.5 rounded-full"
  70. style={{
  71. backgroundColor: tray.tray_color ? hexToRgb(tray.tray_color) : '#808080',
  72. }}
  73. />
  74. ))}
  75. </div>
  76. </button>
  77. ))}
  78. </div>
  79. {/* AMS Content */}
  80. {selectedUnit && (
  81. <div className="bg-bambu-dark-secondary rounded-[10px] p-2.5 pb-0 overflow-visible">
  82. {/* AMS Header - Humidity & Temp */}
  83. <div className="flex items-center gap-2.5 text-xs text-bambu-gray mb-2">
  84. {selectedUnit.humidity !== null && (
  85. <span className="flex items-center gap-1">
  86. <img src="/icons/water.svg" alt="" className="w-3.5 icon-theme" />
  87. {selectedUnit.humidity} %
  88. </span>
  89. )}
  90. {selectedUnit.temp !== null && (
  91. <span className="flex items-center gap-1">
  92. <img src="/icons/temperature.svg" alt="" className="w-3.5 icon-theme" />
  93. {selectedUnit.temp}°C
  94. </span>
  95. )}
  96. <span className="text-yellow-500 text-sm">☀</span>
  97. </div>
  98. {/* Slot Labels */}
  99. <div className="flex justify-center gap-1.5 mb-1.5">
  100. {selectedUnit.tray.map((tray, index) => (
  101. <div
  102. key={tray.id}
  103. className="w-12 flex items-center justify-center gap-0.5 text-[10px] text-bambu-gray px-1.5 py-[3px] bg-bambu-dark rounded-full border border-bambu-dark-tertiary"
  104. >
  105. {slotPrefix}{index + 1}
  106. <img src="/icons/reload.svg" alt="" className="w-2.5 h-2.5 icon-theme" />
  107. </div>
  108. ))}
  109. </div>
  110. {/* AMS Slots with integrated wiring */}
  111. <div className="flex justify-center gap-1.5 mb-0">
  112. {selectedUnit.tray.map((tray) => {
  113. const globalTrayId = selectedUnit.id * 4 + tray.id;
  114. const isSelected = selectedTray === globalTrayId;
  115. const isEmpty = !tray.tray_type || tray.tray_type === '' || tray.tray_type === 'NONE';
  116. const isLight = isLightColor(tray.tray_color);
  117. return (
  118. <div key={tray.id} className="flex flex-col items-center">
  119. <button
  120. onClick={() => !isEmpty && onSelectTray(isSelected ? null : globalTrayId)}
  121. disabled={isEmpty || isPrinting}
  122. className={`w-12 h-[70px] rounded-md border-2 overflow-hidden transition-all bg-bambu-dark ${
  123. isSelected
  124. ? 'border-[#d4a84b]'
  125. : 'border-bambu-dark-tertiary hover:border-bambu-gray'
  126. } ${isEmpty ? 'opacity-50' : ''} disabled:cursor-not-allowed`}
  127. >
  128. <div
  129. className="w-full h-full flex flex-col items-center justify-end pb-[5px]"
  130. style={{
  131. backgroundColor: isEmpty ? undefined : hexToRgb(tray.tray_color),
  132. }}
  133. >
  134. <span
  135. className={`text-[11px] font-semibold mb-1 ${
  136. isLight ? 'text-gray-800' : 'text-white'
  137. } ${isLight ? '' : 'drop-shadow-sm'}`}
  138. >
  139. {isEmpty ? '--' : tray.tray_type}
  140. </span>
  141. {!isEmpty && (
  142. <img
  143. src="/icons/eye.svg"
  144. alt=""
  145. className={`w-3.5 h-3.5 ${isLight ? '' : 'invert'}`}
  146. style={{ opacity: 0.8 }}
  147. />
  148. )}
  149. </div>
  150. </button>
  151. {/* Vertical wire from slot center down */}
  152. <div className="w-[2px] h-[14px] bg-[#909090]" />
  153. </div>
  154. );
  155. })}
  156. </div>
  157. {/* Wiring visualization - horizontal bar and hub */}
  158. <div className="flex justify-center">
  159. <div className="relative h-[50px]" style={{ width: '210px' }}>
  160. {/* Horizontal bar connecting all slots (spans from first to last slot center) */}
  161. <div className="absolute left-[24px] right-[24px] top-0 border-t-2 border-[#909090]" />
  162. {/* Center hub box on the horizontal bar */}
  163. <div className="absolute left-1/2 -translate-x-1/2 top-[-6px] w-[28px] h-[14px] bg-[#c0c0c0] border border-[#909090] rounded-sm" />
  164. {/* Vertical wire from hub going down */}
  165. <div className="absolute left-1/2 -translate-x-[1px] top-[8px] h-[14px] border-l-2 border-[#909090]" />
  166. {/* Horizontal wire from hub toward the center of the panel (extends beyond panel edge) */}
  167. {side === 'left' && (
  168. <div className="absolute left-1/2 top-[21px] w-[calc(50%+30px)] border-t-2 border-[#909090]" />
  169. )}
  170. {side === 'right' && (
  171. <div className="absolute right-1/2 top-[21px] w-[calc(50%+30px)] border-t-2 border-[#909090]" />
  172. )}
  173. </div>
  174. </div>
  175. </div>
  176. )}
  177. {/* No AMS message */}
  178. {units.length === 0 && (
  179. <div className="bg-bambu-dark-secondary rounded-[10px] p-6 text-center text-bambu-gray text-sm">
  180. No AMS connected to {side} nozzle
  181. </div>
  182. )}
  183. </div>
  184. );
  185. }
  186. export function AMSSectionDual({ printerId, status, nozzleCount }: AMSSectionDualProps) {
  187. const isConnected = status?.connected ?? false;
  188. const isPrinting = status?.state === 'RUNNING';
  189. const isDualNozzle = nozzleCount > 1;
  190. const amsUnits: AMSUnit[] = status?.ams ?? [];
  191. // For dual nozzle, split AMS units between left and right
  192. // In real implementation, this would be based on actual nozzle assignment
  193. const leftUnits = isDualNozzle ? amsUnits.filter((_, i) => i % 2 === 0) : amsUnits;
  194. const rightUnits = isDualNozzle ? amsUnits.filter((_, i) => i % 2 === 1) : [];
  195. const [leftAmsIndex, setLeftAmsIndex] = useState(0);
  196. const [rightAmsIndex, setRightAmsIndex] = useState(0);
  197. const [selectedTray, setSelectedTray] = useState<number | null>(null);
  198. const loadMutation = useMutation({
  199. mutationFn: (trayId: number) => api.amsLoadFilament(printerId, trayId),
  200. });
  201. const unloadMutation = useMutation({
  202. mutationFn: () => api.amsUnloadFilament(printerId),
  203. });
  204. const handleLoad = () => {
  205. if (selectedTray !== null) {
  206. loadMutation.mutate(selectedTray);
  207. }
  208. };
  209. const handleUnload = () => {
  210. unloadMutation.mutate();
  211. };
  212. const isLoading = loadMutation.isPending || unloadMutation.isPending;
  213. return (
  214. <div className="bg-bambu-dark-tertiary rounded-[10px] p-3 relative overflow-visible">
  215. {/* Center wiring and Extruder - absolutely centered between the two AMS panels */}
  216. {isDualNozzle && (
  217. <>
  218. {/* Center wiring: two vertical lines going down to extruder inlets */}
  219. {/* Positioned to connect with horizontal wires from AMS panels */}
  220. <div className="absolute left-1/2 -translate-x-1/2 bottom-[62px] pointer-events-none" style={{ width: '24px', height: '30px' }}>
  221. {/* Left vertical line - connects to left AMS horizontal wire, goes to left extruder inlet */}
  222. <div className="absolute left-0 top-0 h-full border-l-2 border-[#909090]" />
  223. {/* Right vertical line - connects to right AMS horizontal wire, goes to right extruder inlet */}
  224. <div className="absolute right-0 top-0 h-full border-l-2 border-[#909090]" />
  225. </div>
  226. {/* Extruder */}
  227. <img
  228. src="/icons/extruder-left-right.png"
  229. alt="Extruder"
  230. className="absolute h-[50px] left-1/2 -translate-x-1/2 bottom-[12px]"
  231. />
  232. </>
  233. )}
  234. {/* Dual Panel Layout */}
  235. <div className="flex gap-5 overflow-visible">
  236. {/* Left Nozzle Panel */}
  237. <AMSPanelContent
  238. units={leftUnits}
  239. side="left"
  240. isPrinting={isPrinting}
  241. selectedAmsIndex={leftAmsIndex}
  242. onSelectAms={setLeftAmsIndex}
  243. selectedTray={selectedTray}
  244. onSelectTray={setSelectedTray}
  245. />
  246. {/* Right Nozzle Panel - only for dual nozzle */}
  247. {isDualNozzle && (
  248. <AMSPanelContent
  249. units={rightUnits}
  250. side="right"
  251. isPrinting={isPrinting}
  252. selectedAmsIndex={rightAmsIndex}
  253. onSelectAms={setRightAmsIndex}
  254. selectedTray={selectedTray}
  255. onSelectTray={setSelectedTray}
  256. />
  257. )}
  258. </div>
  259. {/* Action Buttons Row with Extruder */}
  260. <div className="flex items-start pt-2">
  261. {/* Left buttons */}
  262. <div className="flex items-center gap-2">
  263. <button className="w-10 h-10 rounded-lg bg-bambu-dark-secondary hover:bg-bambu-dark border border-bambu-dark-tertiary flex items-center justify-center">
  264. <img src="/icons/ams-settings.svg" alt="Settings" className="w-5 icon-theme" />
  265. </button>
  266. <button className="px-[18px] py-2.5 rounded-lg bg-bambu-dark-secondary hover:bg-bambu-dark border border-bambu-dark-tertiary text-sm text-bambu-gray flex items-center gap-1.5">
  267. Auto-refill
  268. </button>
  269. </div>
  270. {/* Spacer */}
  271. <div className="flex-1" />
  272. {/* Right buttons */}
  273. <div className="flex items-center gap-2">
  274. <button
  275. onClick={handleUnload}
  276. disabled={!isConnected || isPrinting || isLoading}
  277. className="px-7 py-2.5 rounded-lg bg-bambu-dark hover:bg-bambu-dark-secondary text-sm text-bambu-gray disabled:opacity-50 disabled:cursor-not-allowed"
  278. >
  279. {unloadMutation.isPending ? (
  280. <Loader2 className="w-4 h-4 animate-spin" />
  281. ) : (
  282. 'Unload'
  283. )}
  284. </button>
  285. <button
  286. onClick={handleLoad}
  287. disabled={!isConnected || isPrinting || selectedTray === null || isLoading}
  288. className="px-7 py-2.5 rounded-lg bg-bambu-dark hover:bg-bambu-dark-secondary text-sm text-bambu-gray disabled:opacity-50 disabled:cursor-not-allowed"
  289. >
  290. {loadMutation.isPending ? (
  291. <Loader2 className="w-4 h-4 animate-spin" />
  292. ) : (
  293. 'Load'
  294. )}
  295. </button>
  296. </div>
  297. </div>
  298. {/* Error messages */}
  299. {(loadMutation.error || unloadMutation.error) && (
  300. <p className="mt-2 text-sm text-red-500 text-center">
  301. {(loadMutation.error || unloadMutation.error)?.message}
  302. </p>
  303. )}
  304. </div>
  305. );
  306. }