import { inject, observer } from "mobx-react";
|
import { types } from "mobx-state-tree";
|
|
import InfoModal from "../../components/Infomodal/Infomodal";
|
import { guidGenerator } from "../../core/Helpers";
|
import Registry from "../../core/Registry";
|
import { AnnotationMixin } from "../../mixins/AnnotationMixin";
|
import PerRegionMixin from "../../mixins/PerRegion";
|
import RequiredMixin from "../../mixins/Required";
|
import { isDefined } from "../../utils/utilities";
|
import ControlBase from "./Base";
|
import { ReadOnlyControlMixin } from "../../mixins/ReadOnlyMixin";
|
import ClassificationBase from "./ClassificationBase";
|
import PerItemMixin from "../../mixins/PerItem";
|
import { FF_LSDV_4583, isFF } from "../../utils/feature-flags";
|
import { cn } from "../../utils/bem";
|
import { safeNumber, positiveNumber } from "../../utils/number";
|
|
/**
|
* The Number tag supports numeric classification. Use to classify tasks using numbers.
|
*
|
* Use with the following data types: audio, image, HTML, paragraphs, text, time series, video
|
*
|
* @example
|
* <!--Basic labeling configuration for numeric classification of text -->
|
* <View>
|
* <Text name="txt" value="$text" />
|
* <Number name="number" toName="txt" max="10" />
|
* </View>
|
*
|
* @name Number
|
* @meta_title Number Tag to Numerically Classify
|
* @meta_description Customize Label Studio with the Number tag to numerically classify tasks in your machine learning and data science projects.
|
* @param {string} name - Name of the element
|
* @param {string} toName - Name of the element that you want to label
|
* @param {number} [min] - Minimum number value
|
* @param {number} [max] - Maximum number value
|
* @param {number} [step=1] - Step for value increment/decrement
|
* @param {number} [defaultValue] - Default number value; will be added automatically to result for required fields
|
* @param {string} [hotkey] - Hotkey for increasing number value
|
* @param {boolean} [required=false] - Whether number is required or not
|
* @param {string} [requiredMessage] - Message to show if validation fails
|
* @param {boolean} [perRegion] - Use this tag to classify specific regions instead of the whole object
|
* @param {boolean} [perItem] - Use this tag to classify specific items inside the object instead of the whole object
|
* @param {boolean} [slider=false] - Use slider look instead of input; use min and max to add your constraints
|
*/
|
const TagAttrs = types.model({
|
toname: types.maybeNull(types.string),
|
|
min: types.maybeNull(types.string),
|
max: types.maybeNull(types.string),
|
step: types.maybeNull(types.string),
|
defaultvalue: types.maybeNull(types.string),
|
slider: types.optional(types.boolean, false),
|
|
hotkey: types.maybeNull(types.string),
|
});
|
|
const Model = types
|
.model({
|
pid: types.optional(types.string, guidGenerator),
|
type: "number",
|
number: types.maybeNull(types.number),
|
})
|
.views((self) => ({
|
selectedValues() {
|
return self.number;
|
},
|
|
get holdsState() {
|
return isDefined(self.number);
|
},
|
}))
|
.actions((self) => {
|
const Super = { validateValue: self.validateValue };
|
|
return {
|
validateValue(value) {
|
if (!Super.validateValue(value)) return false;
|
if (!isDefined(value)) return true;
|
|
const errors = [];
|
|
if (isDefined(self.min) && value < self.min) {
|
errors.push(`Value must be greater than or equal to ${self.min}`);
|
}
|
if (isDefined(self.max) && value > self.max) {
|
errors.push(`Value must be less than or equal to ${self.max}`);
|
}
|
if (isDefined(self.step)) {
|
const step = Number.parseFloat(self.step);
|
const basis = isDefined(self.min) ? +self.min : 0;
|
|
const diff = value - basis;
|
const nearest = Math.round(diff / step) * step + basis;
|
|
// EPSILON will handle the floating-point imprecision of the binary representation (IEEE 754)
|
const EPSILON = 1e-8;
|
if (Math.abs(value - nearest) > EPSILON) {
|
const lower = Math.floor(diff / step) * step + basis;
|
const upper = lower + step;
|
const decimals = step.toString().split(".")[1]?.length || 0;
|
errors.push(`The two nearest valid values are ${lower.toFixed(decimals)} and ${upper.toFixed(decimals)}`);
|
}
|
}
|
if (errors.length) {
|
InfoModal.warning(`Number "${value}" is not valid: ${errors.join(", ")}.`);
|
return false;
|
}
|
return true;
|
},
|
getSelectedString() {
|
return `${self.number} star`;
|
},
|
|
needsUpdate() {
|
if (self.result) self.number = self.result.mainValue;
|
else self.number = null;
|
},
|
|
beforeSend() {
|
if (!isDefined(self.defaultvalue)) return;
|
|
// let's fix only required perRegions
|
if (self.perregion && self.required) {
|
const object = self.toNameTag;
|
|
for (const reg of object?.allRegs ?? []) {
|
// add result with default value to every region of related object without number yet
|
if (!reg.results.some((r) => r.from_name === self)) {
|
reg.results.push({
|
area: reg,
|
from_name: self,
|
to_name: object,
|
type: self.resultType,
|
value: {
|
[self.valueType]: +self.defaultvalue,
|
},
|
});
|
}
|
}
|
} else {
|
// add defaultValue to results for top-level controls
|
if (!isDefined(self.number)) self.setNumber(+self.defaultvalue);
|
}
|
},
|
|
unselectAll() {},
|
|
setNumber(value) {
|
self.number = value;
|
self.updateResult();
|
},
|
|
onChange(e) {
|
const value = +e.target.value;
|
|
if (!isNaN(value)) {
|
self.setNumber(value);
|
// without this line we can have `7` in model field while it's displayed as `007`.
|
// at least it is bad for testing cases
|
e.target.value = isDefined(self.number) ? self.number : "";
|
}
|
},
|
|
updateFromResult() {
|
this.needsUpdate();
|
},
|
|
requiredModal() {
|
InfoModal.warning(self.requiredmessage || `Number "${self.name}" is required.`);
|
},
|
|
increaseValue() {
|
const min = safeNumber(self.min, 0);
|
const max = safeNumber(self.max, Number.POSITIVE_INFINITY);
|
const step = positiveNumber(self.step);
|
const defaultValue = safeNumber(self.defaultvalue, min);
|
const current = safeNumber(self.number, defaultValue);
|
|
const next = current + step > max ? min : current + step;
|
|
self.setNumber(next);
|
},
|
|
onHotKey() {
|
return self.increaseValue();
|
},
|
};
|
});
|
|
const NumberModel = types.compose(
|
"NumberModel",
|
ControlBase,
|
ClassificationBase,
|
RequiredMixin,
|
ReadOnlyControlMixin,
|
PerRegionMixin,
|
...(isFF(FF_LSDV_4583) ? [PerItemMixin] : []),
|
AnnotationMixin,
|
TagAttrs,
|
Model,
|
);
|
|
const HtxNumber = inject("store")(
|
observer(({ item, store }) => {
|
const visibleStyle = item.perRegionVisible() ? { display: "flex", alignItems: "center" } : { display: "none" };
|
const sliderStyle = item.slider ? { padding: "9px 0px", border: 0 } : {};
|
const disabled = item.isReadOnly();
|
const numberClassName = cn("number").toClassName();
|
|
return (
|
<div className={numberClassName} style={visibleStyle} ref={item.elementRef}>
|
<input
|
disabled={disabled}
|
style={sliderStyle}
|
type={item.slider ? "range" : "number"}
|
name={item.name}
|
value={item.number ?? item.defaultvalue ?? ""}
|
step={item.step ?? 1}
|
min={isDefined(item.min) ? Number(item.min) : undefined}
|
max={isDefined(item.max) ? Number(item.max) : undefined}
|
onChange={disabled ? undefined : item.onChange}
|
/>
|
{item.slider && <output style={{ marginLeft: "5px" }}>{item.number ?? item.defaultvalue ?? ""}</output>}
|
{store.settings.enableTooltips && store.settings.enableHotkeys && item.hotkey && (
|
<sup style={{ fontSize: "9px" }}>[{item.hotkey}]</sup>
|
)}
|
</div>
|
);
|
}),
|
);
|
|
Registry.addTag("number", NumberModel, HtxNumber);
|
|
export { HtxNumber, NumberModel };
|