New number calculator functionality.
Some checks failed
farmcontrol/farmcontrol-ui/pipeline/head There was a failure building this commit

This commit is contained in:
Tom Butcher 2026-03-06 23:38:30 +00:00
parent 8150372fda
commit e4255443a0
6 changed files with 217 additions and 3 deletions

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 64 64" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(0.624686,0,0,0.624686,3,17.237995)">
<path d="M45.17,47.212C46.58,47.212 47.578,46.42 47.578,45.039C47.578,44.432 47.455,44.033 47.022,43.038C43.627,36.904 41.807,30.56 41.807,23.653C41.807,16.937 43.541,10.39 47.022,4.224C47.455,3.24 47.578,2.84 47.578,2.233C47.578,0.895 46.58,0.05 45.17,0.05C43.622,0.05 42.412,0.807 41.094,2.698C36.763,8.399 34.637,15.598 34.637,23.632C34.637,31.696 36.75,38.726 41.094,44.574C42.402,46.475 43.622,47.212 45.17,47.212ZM53.441,39.915C54.789,39.915 55.627,39.472 56.736,37.85L63.381,28.189L63.526,28.189L70.367,38.036C71.337,39.448 72.177,39.915 73.434,39.915C75.47,39.915 76.876,38.574 76.876,36.677C76.876,35.855 76.63,35.133 76.075,34.383L68.175,23.647L76.006,13.227C76.629,12.367 76.927,11.646 76.927,10.722C76.927,8.851 75.474,7.596 73.587,7.596C72.104,7.596 71.3,8.297 70.379,9.72L64.033,19.158L63.877,19.158L57.501,9.689C56.548,8.256 55.66,7.596 54.075,7.596C52.07,7.596 50.568,9.08 50.568,10.866C50.568,11.932 50.899,12.683 51.435,13.403L59.08,23.63L51.105,34.459C50.429,35.36 50.226,36.071 50.226,36.964C50.226,38.669 51.625,39.915 53.441,39.915ZM82.304,47.212C83.862,47.212 85.062,46.465 86.38,44.574C90.754,38.736 92.847,31.696 92.847,23.632C92.847,15.598 90.682,8.419 86.38,2.698C85.072,0.797 83.862,0.05 82.304,0.05C80.894,0.05 79.896,0.895 79.896,2.233C79.896,2.84 80.029,3.24 80.452,4.224C83.933,10.39 85.667,16.937 85.667,23.653C85.667,30.56 83.848,36.904 80.452,43.038C80.029,44.033 79.896,44.432 79.896,45.039C79.896,46.377 80.894,47.212 82.304,47.212Z" style="fill-rule:nonzero;"/>
<path d="M5.252,47.17C12.053,47.17 15.203,44.455 16.783,37.027L20.127,21.046L25.664,21.046C27.685,21.046 29.099,19.888 29.099,17.909C29.099,16.108 27.887,15.025 26.141,15.025L21.395,15.025L22.161,11.302C22.935,7.691 24.066,6.295 27.165,6.295C27.697,6.295 28.187,6.263 28.56,6.222C30.37,5.986 31.317,5.049 31.317,3.437C31.317,1.14 29.52,0.071 25.884,0.071C19.19,0.071 15.859,3.04 14.354,10.214L13.352,15.025L9.606,15.025C7.544,15.025 6.149,16.193 6.149,18.163C6.149,19.974 7.342,21.046 9.139,21.046L12.084,21.046L8.945,35.949C8.159,39.645 7.007,40.946 3.961,40.946C3.534,40.946 3.096,40.987 2.775,41.019C0.93,41.266 0,42.325 0,43.856C0,46.1 1.765,47.17 5.252,47.17Z" style="fill-rule:nonzero;"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

@ -417,3 +417,32 @@ body {
.ant-badge .ant-badge-count-sm { .ant-badge .ant-badge-count-sm {
font-size: 10px; font-size: 10px;
} }
.input-number-cal {
position: relative;
}
.input-number-cal .ant-input-suffix {
margin-right: 28px;
}
.input-number-cal .ant-input-outlined {
border-color: var(--color-purple);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-purple) 31%, transparent);
}
.input-number-cal .ant-input {
font-family: 'DM Mono';
font-weight: 400;
}
.input-number-cal-icon {
position: absolute;
right: 10px;
top: 0;
bottom: 1px;
display: flex;
align-items: center;
justify-content: center;
z-index: 1;
}

View File

@ -0,0 +1,167 @@
import { useState, useRef } from 'react'
import { Input, InputNumber } from 'antd'
import PropTypes from 'prop-types'
import FunctionIcon from '../../Icons/FunctionIcon'
const OPERATOR_KEYS = ['+', '-', '*', '/']
/**
* Safely evaluate a math expression. Only allows numbers and +, -, *, /
*/
function safeEval(expr) {
const sanitized = String(expr)
.replace(/\s/g, '')
.replace(/[^0-9+\-*/().]/g, '')
if (!sanitized) return null
try {
const fn = new Function(`return (${sanitized})`)
const result = fn()
return typeof result === 'number' && Number.isFinite(result) ? result : null
} catch {
return null
}
}
const InputNumberCal = ({
value,
onChange,
onBlur,
min,
max,
prefix,
suffix,
placeholder,
disabled,
...rest
}) => {
const [isExprMode, setIsExprMode] = useState(false)
const [exprValue, setExprValue] = useState('')
const inputRef = useRef(null)
const switchToExprMode = (initialValue) => {
setIsExprMode(true)
setExprValue(initialValue)
setTimeout(() => {
//inputRef.current?.focus()
const input = inputRef.current.getElementsByTagName('input')[0]
if (input) {
input.focus()
}
}, 0)
}
const exitExprMode = (result) => {
setIsExprMode(false)
setExprValue('')
if (result != null) {
const clamped =
min != null && result < min
? min
: max != null && result > max
? max
: result
onChange?.(clamped)
}
}
const handleNumberKeyDown = (e) => {
if (OPERATOR_KEYS.includes(e.key)) {
e.preventDefault()
const current = value ?? ''
switchToExprMode(String(current) + e.key)
}
}
const handleInputChange = (e) => {
const next = e.target.value
setExprValue(next)
const num = parseFloat(next)
if (!next) {
onChange?.(null)
} else if (!next.match(/[+\-*/=]/) && !Number.isNaN(num)) {
onChange?.(num)
}
}
const handleInputKeyDown = (e) => {
if (e.key === '=') {
e.preventDefault()
const result = safeEval(exprValue)
if (result != null) {
exitExprMode(result)
}
}
}
const handleInputBlur = (e) => {
const expr = exprValue.trim()
if (expr && expr.match(/[+\-*/]/)) {
const result = safeEval(expr)
if (result != null) {
exitExprMode(result)
} else {
exitExprMode(null)
}
} else {
const num = parseFloat(expr)
if (!Number.isNaN(num)) {
exitExprMode(num)
} else {
exitExprMode(null)
}
}
onBlur?.(e)
}
const commonProps = {
prefix,
suffix,
placeholder,
disabled,
min,
max,
...rest
}
if (isExprMode) {
return (
<div className='input-number-cal' ref={inputRef}>
<Input
value={exprValue}
onChange={handleInputChange}
onKeyDown={handleInputKeyDown}
onBlur={handleInputBlur}
{...commonProps}
/>
<div className='input-number-cal-icon'>
<FunctionIcon style={{ fontSize: 24 }} />
</div>
</div>
)
}
return (
<InputNumber
value={value}
onChange={onChange}
onBlur={onBlur}
onKeyDown={handleNumberKeyDown}
{...commonProps}
/>
)
}
InputNumberCal.propTypes = {
value: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
onChange: PropTypes.func,
onBlur: PropTypes.func,
min: PropTypes.number,
max: PropTypes.number,
step: PropTypes.number,
prefix: PropTypes.node,
suffix: PropTypes.node,
placeholder: PropTypes.string,
disabled: PropTypes.bool
}
export default InputNumberCal

View File

@ -27,6 +27,7 @@ import { getPropertyValue } from '../../../database/ObjectModels'
import PropertyChanges from './PropertyChanges' import PropertyChanges from './PropertyChanges'
import NetGrossDisplay from './NetGrossDisplay' import NetGrossDisplay from './NetGrossDisplay'
import NetGrossInput from './NetGrossInput' import NetGrossInput from './NetGrossInput'
import InputNumberCal from './InputNumberCal'
import ObjectList from './ObjectList' import ObjectList from './ObjectList'
import VarianceDisplay from './VarianceDisplay' import VarianceDisplay from './VarianceDisplay'
import OperationDisplay from './OperationDisplay' import OperationDisplay from './OperationDisplay'
@ -713,7 +714,7 @@ const ObjectProperty = ({
) )
case 'number': case 'number':
return ( return (
<InputNumber <InputNumberCal
placeholder={label} placeholder={label}
prefix={prefix} prefix={prefix}
suffix={suffix} suffix={suffix}

View File

@ -13,7 +13,7 @@ const COLORS = {
colorLink: '#5AC8F5', colorLink: '#5AC8F5',
colorCyan: '#5AC8F5', colorCyan: '#5AC8F5',
colorPink: '#FF69B4', colorPink: '#FF69B4',
colorPurple: '#800080', colorPurple: '#6B5DFF',
colorMagenta: '#FF00FF', colorMagenta: '#FF00FF',
colorVolcano: '#FF4500' colorVolcano: '#FF4500'
} }
@ -114,7 +114,10 @@ export const ThemeProvider = ({ children }) => {
root.style.setProperty('--color-purple', COLORS.colorPurple) root.style.setProperty('--color-purple', COLORS.colorPurple)
root.style.setProperty('--color-magenta', COLORS.colorMagenta) root.style.setProperty('--color-magenta', COLORS.colorMagenta)
root.style.setProperty('--color-volcano', COLORS.colorVolcano) root.style.setProperty('--color-volcano', COLORS.colorVolcano)
root.style.setProperty('--layout-header-bg', isDarkMode ? '#141414' : '#ffffff') root.style.setProperty(
'--layout-header-bg',
isDarkMode ? '#141414' : '#ffffff'
)
}, [isDarkMode]) }, [isDarkMode])
const themeConfig = { const themeConfig = {

View File

@ -0,0 +1,6 @@
import Icon from '@ant-design/icons'
import CustomIconSvg from '../../../assets/icons/functionicon.svg?react'
const FunctionIcon = (props) => <Icon component={CustomIconSvg} {...props} />
export default FunctionIcon