Skip to content

Commit cdf39e6

Browse files
committed
Better fan curve input including removing points and visualizing curve
1 parent 96c5353 commit cdf39e6

3 files changed

Lines changed: 291 additions & 46 deletions

File tree

frontend/src/App.jsx

Lines changed: 2 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -2,28 +2,15 @@ import React, { Component } from "react"
22
import "antd/dist/antd.css"
33
import { getWebclient } from "./api/index"
44
import Switch from "./components/Switch"
5+
import FanCurveGraph from "./components/FanCurveGraph"
56
import { Layout, Menu, Typography, Card, Row, Col, Statistic, Divider, Tooltip, Tabs, Button, Input, Form, Popover } from "antd"
67
import { LaptopOutlined, PlusOutlined, ArrowUpOutlined, ArrowDownOutlined, DeleteOutlined, SaveOutlined, SettingOutlined } from "@ant-design/icons"
7-
import InlineSlider from "./components/InlineSlider"
88

99
const { TabPane } = Tabs
1010
const { SubMenu } = Menu
1111
const { Content, Sider } = Layout
1212
let socket
1313

14-
let sliderMarks = {
15-
0: "0%",
16-
25: "25%",
17-
50: "50%",
18-
75: "75%",
19-
100: {
20-
style: {
21-
color: "#f50",
22-
},
23-
label: <strong>100%</strong>,
24-
},
25-
}
26-
2714
function getValue(key) {
2815
try {
2916
return JSON.parse(localStorage[key])
@@ -202,37 +189,7 @@ class App extends Component {
202189
<Row>
203190
<Col span={layout.labelCol.span}></Col>
204191
<Col span={layout.wrapperCol.span}>
205-
<Form.List {...tailLayout} name="fancurve">
206-
{(fields, { add }) => (
207-
<>
208-
{fields.map((field, index) => (
209-
<div
210-
style={{
211-
...field.style,
212-
display: "inline-block",
213-
}}
214-
>
215-
<Form.Item {...field}>
216-
<InlineSlider
217-
min={0}
218-
max={100}
219-
vertical
220-
defaultValue={20}
221-
marks={index === fields.length - 1 && sliderMarks} // Show percentage marks on the rightmost slider
222-
// tooltipVisible={true} // Is kinda nice, but cluttered and lags *a lot*
223-
/>
224-
</Form.Item>
225-
<p>{Math.floor((index / (fields.length - 1)) * 100)}°C</p>
226-
</div>
227-
))}
228-
<Form.Item>
229-
<Button type="dashed" onClick={() => add()} icon={<PlusOutlined />}>
230-
Add point to curve
231-
</Button>
232-
</Form.Item>
233-
</>
234-
)}
235-
</Form.List>
192+
<FanCurveGraph />
236193
</Col>
237194
</Row>
238195
<Form.Item {...tailLayout}>
Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
1+
import React from "react"
2+
import { Form, Button, Tooltip } from "antd"
3+
import { PlusOutlined, ArrowUpOutlined, ArrowDownOutlined, DeleteOutlined } from "@ant-design/icons"
4+
import InlineSlider from "./InlineSlider"
5+
6+
const FanCurveGraph = ({ form }) => {
7+
return (
8+
<Form.List name="fancurve">
9+
{(fields, { add, remove, move }) => (
10+
<>
11+
<Form.Item noStyle shouldUpdate>
12+
{({ getFieldValue }) => {
13+
const fancurve = getFieldValue("fancurve") || []
14+
return (
15+
<div
16+
style={{
17+
position: "relative",
18+
padding: "40px 40px 60px 60px",
19+
backgroundColor: "#fafafa",
20+
border: "1px solid #d9d9d9",
21+
borderRadius: "4px",
22+
userSelect: "none",
23+
WebkitUserSelect: "none",
24+
MozUserSelect: "none",
25+
msUserSelect: "none",
26+
}}
27+
>
28+
{/* Y-axis label */}
29+
<div
30+
style={{
31+
position: "absolute",
32+
left: "10px",
33+
top: "50%",
34+
transform: "rotate(-90deg) translateX(-50%)",
35+
transformOrigin: "left center",
36+
fontWeight: "bold",
37+
fontSize: "12px",
38+
color: "#595959",
39+
}}
40+
>
41+
Fan Speed (%)
42+
</div>
43+
44+
{/* X-axis label */}
45+
<div
46+
style={{
47+
position: "absolute",
48+
bottom: "10px",
49+
left: "50%",
50+
transform: "translateX(-50%)",
51+
fontWeight: "bold",
52+
fontSize: "12px",
53+
color: "#595959",
54+
}}
55+
>
56+
Temperature (°C)
57+
</div>
58+
59+
{/* Grid background */}
60+
<div style={{ position: "relative", height: "300px", backgroundColor: "#fff", border: "1px solid #e8e8e8" }}>
61+
{/* Horizontal grid lines */}
62+
{[0, 25, 50, 75, 100].map((val) => (
63+
<div
64+
key={`h-${val}`}
65+
style={{
66+
position: "absolute",
67+
left: 0,
68+
right: 0,
69+
bottom: `${val}%`,
70+
height: "1px",
71+
backgroundColor: val === 0 ? "#d9d9d9" : "#f0f0f0",
72+
zIndex: 1,
73+
}}
74+
>
75+
<span
76+
style={{
77+
position: "absolute",
78+
left: "-35px",
79+
top: "-8px",
80+
fontSize: "11px",
81+
color: "#8c8c8c",
82+
}}
83+
>
84+
{val}%
85+
</span>
86+
</div>
87+
))}
88+
89+
{/* SVG for connecting lines and curve fill */}
90+
<svg
91+
style={{
92+
position: "absolute",
93+
top: 0,
94+
left: 0,
95+
width: "100%",
96+
height: "100%",
97+
zIndex: 2,
98+
pointerEvents: "none",
99+
}}
100+
>
101+
{fields.length > 1 && (
102+
<>
103+
{/* Fill area under curve */}
104+
<path
105+
d={
106+
fields.length > 0
107+
? `M 0,100% ` +
108+
fields
109+
.map((field, index) => {
110+
const x = (index / (fields.length - 1)) * 100
111+
const y = 100 - (fancurve[index] || 0)
112+
return `L ${x}%,${y}%`
113+
})
114+
.join(" ") +
115+
` L 100%,100% Z`
116+
: ""
117+
}
118+
fill="#1890ff"
119+
fillOpacity="0.1"
120+
/>
121+
{/* Connecting lines */}
122+
{fields.map((field, index) => {
123+
if (index === fields.length - 1) return null
124+
const x1 = (index / (fields.length - 1)) * 100
125+
const x2 = ((index + 1) / (fields.length - 1)) * 100
126+
const y1 = 100 - (fancurve[index] || 0)
127+
const y2 = 100 - (fancurve[index + 1] || 0)
128+
return (
129+
<line
130+
key={`line-${field.key}`}
131+
x1={`${x1}%`}
132+
y1={`${y1}%`}
133+
x2={`${x2}%`}
134+
y2={`${y2}%`}
135+
stroke="#1890ff"
136+
strokeWidth="3"
137+
/>
138+
)
139+
})}
140+
{/* Point markers */}
141+
{fields.map((field, index) => {
142+
const x = (index / (fields.length - 1)) * 100
143+
const y = 100 - (fancurve[index] || 0)
144+
return (
145+
<circle
146+
key={`point-${field.key}`}
147+
cx={`${x}%`}
148+
cy={`${y}%`}
149+
r="5"
150+
fill="#1890ff"
151+
stroke="#fff"
152+
strokeWidth="2"
153+
/>
154+
)
155+
})}
156+
</>
157+
)}
158+
</svg>
159+
160+
{/* Curve points with sliders */}
161+
<div style={{ position: "relative", height: "100%", display: "flex", justifyContent: "space-around" }}>
162+
{fields.map((field, index) => (
163+
<div
164+
key={field.key}
165+
style={{
166+
position: "absolute",
167+
left: `calc(${(index / (fields.length - 1)) * 100}% - 15px)`,
168+
top: "-6px",
169+
display: "flex",
170+
flexDirection: "column",
171+
alignItems: "center",
172+
zIndex: 3,
173+
}}
174+
>
175+
<Form.Item
176+
{...field}
177+
style={{
178+
marginBottom: 0,
179+
height: "100%",
180+
}}
181+
>
182+
<InlineSlider min={0} max={100} vertical defaultValue={20} style={{ height: "100%" }} />
183+
</Form.Item>
184+
185+
{/* Control buttons */}
186+
<div
187+
style={{
188+
position: "absolute",
189+
top: "-35px",
190+
display: "flex",
191+
gap: "2px",
192+
backgroundColor: "#fff",
193+
padding: "2px",
194+
borderRadius: "4px",
195+
border: "1px solid #d9d9d9",
196+
boxShadow: "0 2px 4px rgba(0,0,0,0.1)",
197+
}}
198+
>
199+
<Tooltip title="Move left">
200+
<Button
201+
size="small"
202+
icon={<ArrowUpOutlined style={{ transform: "rotate(-90deg)", fontSize: "10px" }} />}
203+
disabled={index === 0}
204+
onClick={() => move(index, index - 1)}
205+
style={{ padding: "0 4px", height: "20px" }}
206+
/>
207+
</Tooltip>
208+
<Tooltip title="Delete">
209+
<Button
210+
size="small"
211+
danger
212+
icon={<DeleteOutlined style={{ fontSize: "10px" }} />}
213+
disabled={fields.length <= 2}
214+
onClick={() => {
215+
if (fields.length > 2) remove(field.name)
216+
}}
217+
style={{ padding: "0 4px", height: "20px" }}
218+
/>
219+
</Tooltip>
220+
<Tooltip title="Move right">
221+
<Button
222+
size="small"
223+
icon={<ArrowDownOutlined style={{ transform: "rotate(-90deg)", fontSize: "10px" }} />}
224+
disabled={index === fields.length - 1}
225+
onClick={() => move(index, index + 1)}
226+
style={{ padding: "0 4px", height: "20px" }}
227+
/>
228+
</Tooltip>
229+
</div>
230+
231+
{/* Temperature label */}
232+
<div
233+
style={{
234+
position: "absolute",
235+
bottom: "-25px",
236+
left: "50%",
237+
transform: "translateX(-50%)",
238+
fontWeight: "bold",
239+
fontSize: "11px",
240+
color: "#262626",
241+
whiteSpace: "nowrap",
242+
}}
243+
>
244+
{Math.floor((index / (fields.length - 1)) * 100)}°C
245+
</div>
246+
</div>
247+
))}
248+
</div>
249+
</div>
250+
</div>
251+
)
252+
}}
253+
</Form.Item>
254+
255+
<Form.Item style={{ marginTop: "16px" }}>
256+
<Button type="dashed" onClick={() => add(20)} icon={<PlusOutlined />}>
257+
Add point to curve
258+
</Button>
259+
<div style={{ marginTop: "8px", fontSize: "12px", color: "#8c8c8c" }}>
260+
{fields.length < 2 && <span style={{ color: "#ff4d4f" }}>⚠ At least 2 points required for fan curve</span>}
261+
{fields.length >= 2 && <span>{fields.length} points defined</span>}
262+
</div>
263+
</Form.Item>
264+
</>
265+
)}
266+
</Form.List>
267+
)
268+
}
269+
270+
export default FanCurveGraph
271+

frontend/src/components/InlineSlider.jsx

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,27 @@ import { Slider } from "antd"
44
class InlineSlider extends React.PureComponent {
55
constructor(props) {
66
super(props)
7+
this.handleWheel = this.handleWheel.bind(this)
78
}
9+
10+
handleWheel(e) {
11+
e.preventDefault()
12+
const { value, onChange, min = 0, max = 100 } = this.props
13+
const currentValue = value || 0
14+
const delta = e.deltaY > 0 ? -1 : 1
15+
const newValue = Math.max(min, Math.min(max, currentValue + delta))
16+
17+
if (onChange && newValue !== currentValue) {
18+
onChange(newValue)
19+
}
20+
}
21+
822
render() {
923
return (
10-
<div style={{ height: 300, width: 80, display: "inline-block" }}>
24+
<div
25+
style={{ height: 300, width: 80, display: "inline-block", userSelect: "none" }}
26+
onWheel={this.handleWheel}
27+
>
1128
<Slider {...this.props} />
1229
</div>
1330
)

0 commit comments

Comments
 (0)