@reekon-tools/boldr-utils 1.17.2 → 1.17.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
- import { Group, Line, Rect, RoundedRect, Skia, rect, rrect, useFont, } from '@shopify/react-native-skia';
2
+ import { Group, Path, Rect, RoundedRect, Skia, rect, rrect, useFont, } from '@shopify/react-native-skia';
3
3
  import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, } from 'react';
4
4
  import { StyleSheet, TouchableOpacity, View, } from 'react-native';
5
5
  import { Gesture, GestureDetector, GestureHandlerRootView, } from 'react-native-gesture-handler';
@@ -44,8 +44,14 @@ const LOUPE_RADIUS = 16; // lens corner radius
44
44
  const LOUPE_BORDER_WIDTH = 2;
45
45
  const LOUPE_BORDER_COLOR = '#1F2937';
46
46
  const LOUPE_BG_COLOR = '#FFFFFF';
47
- const LOUPE_CROSSHAIR_COLOR = '#1F293799'; // ~60% dark marks the exact point
48
- const LOUPE_CROSSHAIR_ARM = 9; // half-length of each crosshair arm, points
47
+ // Black crosshair marking the dragged plane handle INSIDE the lens (replacing
48
+ // the disc, which covered the exact pixel being placed). Sizes are screen
49
+ // points inside the lens. The old fixed center crosshair was removed: the
50
+ // lens center is the FINGER, which sits off the handle by the grab offset,
51
+ // so it marked the wrong point.
52
+ const LOUPE_CROSS_COLOR = '#000000';
53
+ const LOUPE_CROSS_ARM_PX = 9; // half-length of each arm
54
+ const LOUPE_CROSS_WIDTH_PX = 1.5;
49
55
  // Native fingerprint: one finger drives the active tool, two fingers
50
56
  // pan/zoom the viewport. Tap counts as a brief pointer down+up so tools
51
57
  // like measurement-stamp (which only listen to onPointerUp) work via tap.
@@ -478,6 +484,17 @@ export const AnnotationCanvasInner = (props) => {
478
484
  'worklet';
479
485
  return HANDLE_RING_PX / zoom.value;
480
486
  });
487
+ // Loupe variants: divided by the loupe's extra magnification too, so handles
488
+ // stay screen-constant INSIDE the lens as well — chrome shouldn't magnify
489
+ // with the content, and at 2× it covers the very point being placed.
490
+ const loupeHandleRadius = useDerivedValue(() => {
491
+ 'worklet';
492
+ return HANDLE_RADIUS_PX / zoom.value / LOUPE_MAGNIFICATION;
493
+ });
494
+ const loupeHandleRingWidth = useDerivedValue(() => {
495
+ 'worklet';
496
+ return HANDLE_RING_PX / zoom.value / LOUPE_MAGNIFICATION;
497
+ });
481
498
  // ---- Plane-calibration handle drag (UI thread; Tool.planeEdit) ----------
482
499
  // The plane tool's JS preview path re-rendered the whole canvas (and
483
500
  // recomputed the photo-spanning grid) per pointer-move, dropping JS frames
@@ -513,6 +530,29 @@ export const AnnotationCanvasInner = (props) => {
513
530
  y: planeDragStartY.value + planeDragTY.value / zoom.value,
514
531
  };
515
532
  });
533
+ // Black crosshair marking the dragged handle inside the lens, replacing its
534
+ // disc there (the loupe render passes planeDragHandleHidden). Built in
535
+ // SCREEN space and drawn as lens chrome inside the clip (see loupeWrap), so
536
+ // its size is fixed pixels: project the handle through the same math as
537
+ // loupeTransform — lensCenter + MAG · (screen(handle) − finger). That
538
+ // offset from center is the grab offset the drag preserves, which is why a
539
+ // fixed center mark sat visibly OFF the point and was removed.
540
+ const loupeCrossPath = useDerivedValue(() => {
541
+ 'worklet';
542
+ const path = Skia.Path.Make();
543
+ if (planeDragMode.value !== 1)
544
+ return path;
545
+ const p = planeDragHandle.value;
546
+ const sx = (p.x - panX.value) * zoom.value;
547
+ const sy = (p.y - panY.value) * zoom.value;
548
+ const x = loupeCx + LOUPE_MAGNIFICATION * (sx - magTouchX.value);
549
+ const y = loupeCy + LOUPE_MAGNIFICATION * (sy - magTouchY.value);
550
+ path.moveTo(x - LOUPE_CROSS_ARM_PX, y);
551
+ path.lineTo(x + LOUPE_CROSS_ARM_PX, y);
552
+ path.moveTo(x, y - LOUPE_CROSS_ARM_PX);
553
+ path.lineTo(x, y + LOUPE_CROSS_ARM_PX);
554
+ return path;
555
+ }, [loupeCx, loupeCy]);
516
556
  // WORKLET TWIN of PlaneElement's outline path (quad ring / reference line;
517
557
  // the scale ticks are skipped live and reappear on commit). The committed
518
558
  // plane + dragged index are captured from React state — setPlaneDrag
@@ -1913,7 +1953,7 @@ export const AnnotationCanvasInner = (props) => {
1913
1953
  // point under the finger. Drawn in the overlay canvas's screen space (the
1914
1954
  // magnification lives in loupeTransform, applied to `content`).
1915
1955
  const loupeClip = rrect(rect(loupeX, loupeY, loupeSize, loupeSize), LOUPE_RADIUS, LOUPE_RADIUS);
1916
- const loupeWrap = (content) => (_jsxs(_Fragment, { children: [_jsxs(Group, { clip: loupeClip, children: [_jsx(Rect, { x: loupeX, y: loupeY, width: loupeSize, height: loupeSize, color: LOUPE_BG_COLOR }), content] }), _jsx(RoundedRect, { x: loupeX, y: loupeY, width: loupeSize, height: loupeSize, r: LOUPE_RADIUS, color: LOUPE_BORDER_COLOR, style: "stroke", strokeWidth: LOUPE_BORDER_WIDTH }), _jsx(Line, { p1: { x: loupeCx - LOUPE_CROSSHAIR_ARM, y: loupeCy }, p2: { x: loupeCx + LOUPE_CROSSHAIR_ARM, y: loupeCy }, color: LOUPE_CROSSHAIR_COLOR, strokeWidth: 1.5 }), _jsx(Line, { p1: { x: loupeCx, y: loupeCy - LOUPE_CROSSHAIR_ARM }, p2: { x: loupeCx, y: loupeCy + LOUPE_CROSSHAIR_ARM }, color: LOUPE_CROSSHAIR_COLOR, strokeWidth: 1.5 })] }));
1956
+ const loupeWrap = (content) => (_jsxs(_Fragment, { children: [_jsxs(Group, { clip: loupeClip, children: [_jsx(Rect, { x: loupeX, y: loupeY, width: loupeSize, height: loupeSize, color: LOUPE_BG_COLOR }), content, _jsx(Path, { path: loupeCrossPath, color: LOUPE_CROSS_COLOR, style: "stroke", strokeWidth: LOUPE_CROSS_WIDTH_PX })] }), _jsx(RoundedRect, { x: loupeX, y: loupeY, width: loupeSize, height: loupeSize, r: LOUPE_RADIUS, color: LOUPE_BORDER_COLOR, style: "stroke", strokeWidth: LOUPE_BORDER_WIDTH })] }));
1917
1957
  return (
1918
1958
  // overflow hidden mirrors the web container: the Skia surface clips its
1919
1959
  // drawing to the canvas box by construction, but the stamp overlay is
@@ -1935,6 +1975,16 @@ export const AnnotationCanvasInner = (props) => {
1935
1975
  ...skiaProps,
1936
1976
  worldTransform: loupeTransform,
1937
1977
  wrapContent: loupeWrap,
1978
+ // Chrome stays screen-constant inside the lens: handles at their
1979
+ // normal pixel size, the plane outline at its normal apparent
1980
+ // thickness — only the CONTENT magnifies. At 2× the fat
1981
+ // calibration chrome covered the exact point being placed.
1982
+ handleRadius: loupeHandleRadius,
1983
+ handleRingWidth: loupeHandleRingWidth,
1984
+ planeOutlineScale: 1 / LOUPE_MAGNIFICATION,
1985
+ // The dragged handle renders as the black crosshair drawn in the
1986
+ // lens chrome (loupeCrossPath) instead of its disc.
1987
+ planeDragHandleHidden: true,
1938
1988
  }) }))] }));
1939
1989
  };
1940
1990
  const MeasurementStampOverlayItem = ({ placed, measurement, selected, dragging, sliding, endpointDragging, rectResizing, zoomSnapshot, zoom, panX, panY, dragX, dragY, slideCtx, epCtx, rectCtx, planeEdgeDragging, planeTileCtx, planeDragTX, planeDragTY, renderMeasurementStamp, tileScaleFactor, headerTileScaleFactor, fileTileScaleFactor, tileViewportScale, onStampPress, onStampLongPress, onRemove, }) => {
@@ -98,9 +98,11 @@ export interface AnnotationCanvasSkiaProps {
98
98
  value: SkPath;
99
99
  };
100
100
  planeDragHandle?: AnimatedPoint;
101
+ planeOutlineScale?: number;
102
+ planeDragHandleHidden?: boolean;
101
103
  customPreview?: ReactNode;
102
104
  wrapContent?: (content: ReactNode) => ReactNode;
103
105
  canvasRef?: RefObject<CanvasRef | null>;
104
106
  }
105
- export declare const AnnotationCanvasSkia: ({ width, height, effectiveCanvas, worldTransform, viewport, resolveImageUrl, backgroundSkImages, valueFont, textFontMgr, penDrawingStroke, livePreview, shapePreview, draggingId, dragTransform, resizingId, resizeTransform, selectedId, endpointDragId, shapeEndpointDragId, liveLineP1, liveLineP2, rectDragId, liveRect, shapeResizeId, shapeResizePath, shapeResizeBox, handleRadius, handleRingWidth, showPlaneHandles, planeDragId, planeDragIndex, planeDragPath, planeDragGridPath, planeDragHandle, customPreview, wrapContent, canvasRef, }: AnnotationCanvasSkiaProps) => import("react/jsx-runtime").JSX.Element;
107
+ export declare const AnnotationCanvasSkia: ({ width, height, effectiveCanvas, worldTransform, viewport, resolveImageUrl, backgroundSkImages, valueFont, textFontMgr, penDrawingStroke, livePreview, shapePreview, draggingId, dragTransform, resizingId, resizeTransform, selectedId, endpointDragId, shapeEndpointDragId, liveLineP1, liveLineP2, rectDragId, liveRect, shapeResizeId, shapeResizePath, shapeResizeBox, handleRadius, handleRingWidth, showPlaneHandles, planeDragId, planeDragIndex, planeDragPath, planeDragGridPath, planeDragHandle, planeOutlineScale, planeDragHandleHidden, customPreview, wrapContent, canvasRef, }: AnnotationCanvasSkiaProps) => import("react/jsx-runtime").JSX.Element;
106
108
  export {};
@@ -80,7 +80,7 @@ const SelectionBox = ({ bounds, isDragging, transform, }) => (_jsx(DraggableElem
80
80
  // since the function-call pattern works identically on native we use it
81
81
  // in both Inners for consistency. Don't add hooks here; this is a plain
82
82
  // JSX-returning helper, not a component.
83
- export const AnnotationCanvasSkia = ({ width, height, effectiveCanvas, worldTransform, viewport, resolveImageUrl, backgroundSkImages, valueFont, textFontMgr, penDrawingStroke, livePreview, shapePreview, draggingId, dragTransform, resizingId, resizeTransform, selectedId, endpointDragId, shapeEndpointDragId, liveLineP1, liveLineP2, rectDragId, liveRect, shapeResizeId, shapeResizePath, shapeResizeBox, handleRadius, handleRingWidth, showPlaneHandles, planeDragId, planeDragIndex, planeDragPath, planeDragGridPath, planeDragHandle, customPreview, wrapContent, canvasRef, }) => {
83
+ export const AnnotationCanvasSkia = ({ width, height, effectiveCanvas, worldTransform, viewport, resolveImageUrl, backgroundSkImages, valueFont, textFontMgr, penDrawingStroke, livePreview, shapePreview, draggingId, dragTransform, resizingId, resizeTransform, selectedId, endpointDragId, shapeEndpointDragId, liveLineP1, liveLineP2, rectDragId, liveRect, shapeResizeId, shapeResizePath, shapeResizeBox, handleRadius, handleRingWidth, showPlaneHandles, planeDragId, planeDragIndex, planeDragPath, planeDragGridPath, planeDragHandle, planeOutlineScale = 1, planeDragHandleHidden, customPreview, wrapContent, canvasRef, }) => {
84
84
  // Doc rect a SCALE-mode plane grid covers: the first background layer (the
85
85
  // photo being calibrated), else the document rect. Pinned to layer 0 like
86
86
  // the tile-sizing reference — adding a second image must not re-span the
@@ -99,10 +99,12 @@ export const AnnotationCanvasSkia = ({ width, height, effectiveCanvas, worldTran
99
99
  const statics = plane.mode === 'perspective'
100
100
  ? plane.corners
101
101
  : [plane.refLine.a, plane.refLine.b];
102
- return (_jsxs(Group, { children: [planeDragGridPath && (_jsx(Path, { path: planeDragGridPath, color: PLANE_GRID_COLOR, style: "stroke", strokeWidth: PLANE_GRID_WIDTH, opacity: PLANE_GRID_OPACITY })), _jsx(Path, { path: planeDragPath, color: PLANE_CHROME_COLOR, style: "stroke", strokeWidth: PLANE_OUTLINE_WIDTH, strokeJoin: "round" }), handleRadius != null &&
103
- statics.map((p, i) => i === planeDragIndex ? null : (_jsxs(Group, { children: [_jsx(Circle, { c: p, r: handleRadius, color: PLANE_CHROME_COLOR }), _jsx(Circle, { c: p, r: handleRadius, color: "#FFFFFF", style: "stroke", strokeWidth: handleRingWidth })] }, i))), handleRadius != null && planeDragHandle && (_jsxs(_Fragment, { children: [_jsx(Circle, { c: planeDragHandle, r: handleRadius, color: PLANE_CHROME_COLOR }), _jsx(Circle, { c: planeDragHandle, r: handleRadius, color: "#FFFFFF", style: "stroke", strokeWidth: handleRingWidth })] }))] }, plane.id));
102
+ return (_jsxs(Group, { children: [planeDragGridPath && (_jsx(Path, { path: planeDragGridPath, color: PLANE_GRID_COLOR, style: "stroke", strokeWidth: PLANE_GRID_WIDTH, opacity: PLANE_GRID_OPACITY })), _jsx(Path, { path: planeDragPath, color: PLANE_CHROME_COLOR, style: "stroke", strokeWidth: PLANE_OUTLINE_WIDTH * planeOutlineScale, strokeJoin: "round" }), handleRadius != null &&
103
+ statics.map((p, i) => i === planeDragIndex ? null : (_jsxs(Group, { children: [_jsx(Circle, { c: p, r: handleRadius, color: PLANE_CHROME_COLOR }), _jsx(Circle, { c: p, r: handleRadius, color: "#FFFFFF", style: "stroke", strokeWidth: handleRingWidth })] }, i))), handleRadius != null &&
104
+ planeDragHandle &&
105
+ !planeDragHandleHidden && (_jsxs(_Fragment, { children: [_jsx(Circle, { c: planeDragHandle, r: handleRadius, color: PLANE_CHROME_COLOR }), _jsx(Circle, { c: planeDragHandle, r: handleRadius, color: "#FFFFFF", style: "stroke", strokeWidth: handleRingWidth })] }))] }, plane.id));
104
106
  }
105
- return (_jsx(PlaneElement, { plane: plane, scaleBounds: planeScaleBounds, showHandles: showPlaneHandles === true, handleRadius: showPlaneHandles ? handleRadius : undefined, handleRingWidth: showPlaneHandles ? handleRingWidth : undefined }, plane.id));
107
+ return (_jsx(PlaneElement, { plane: plane, scaleBounds: planeScaleBounds, showHandles: showPlaneHandles === true, handleRadius: showPlaneHandles ? handleRadius : undefined, handleRingWidth: showPlaneHandles ? handleRingWidth : undefined, outlineScale: planeOutlineScale }, plane.id));
106
108
  }), effectiveCanvas.strokes.map((stroke) => (_jsx(DraggableElement, { isDragging: stroke.id === draggingId, transform: dragTransform, children: _jsx(StrokeElement, { stroke: stroke }) }, stroke.id))), effectiveCanvas.shapes.map((shape) => {
107
109
  // Line/arrow shapes support endpoint editing. When selected they show
108
110
  // grab handles at both ends; during an endpoint drag the line renders
@@ -18,6 +18,7 @@ export interface PlaneElementProps {
18
18
  showHandles: boolean;
19
19
  handleRadius?: AnimatedNumber;
20
20
  handleRingWidth?: AnimatedNumber;
21
+ outlineScale?: number;
21
22
  }
22
- export declare const PlaneElement: import("react").MemoExoticComponent<({ plane, scaleBounds, showHandles, handleRadius, handleRingWidth, }: PlaneElementProps) => import("react/jsx-runtime").JSX.Element>;
23
+ export declare const PlaneElement: import("react").MemoExoticComponent<({ plane, scaleBounds, showHandles, handleRadius, handleRingWidth, outlineScale, }: PlaneElementProps) => import("react/jsx-runtime").JSX.Element>;
23
24
  export {};
@@ -34,7 +34,7 @@ const segmentsPath = (segments) => {
34
34
  // The grid respects the plane's stored gridSpacingUm (hosts stamp it at
35
35
  // dimension-entry time from the unit the user typed); absent, it falls back
36
36
  // to an auto "nice metric" spacing for the calibrated extent.
37
- export const PlaneElement = memo(({ plane, scaleBounds, showHandles, handleRadius, handleRingWidth, }) => {
37
+ export const PlaneElement = memo(({ plane, scaleBounds, showHandles, handleRadius, handleRingWidth, outlineScale = 1, }) => {
38
38
  const gridVisible = plane.gridVisible !== false;
39
39
  const gridPath = useMemo(() => {
40
40
  if (!gridVisible)
@@ -99,7 +99,7 @@ export const PlaneElement = memo(({ plane, scaleBounds, showHandles, handleRadiu
99
99
  ? plane.corners
100
100
  : [plane.refLine.a, plane.refLine.b]
101
101
  : [];
102
- return (_jsxs(Group, { children: [gridPath && (_jsx(Path, { path: gridPath, color: PLANE_GRID_COLOR, style: "stroke", strokeWidth: PLANE_GRID_WIDTH, opacity: PLANE_GRID_OPACITY })), _jsx(Path, { path: outlinePath, color: PLANE_COLOR, style: "stroke", strokeWidth: PLANE_OUTLINE_WIDTH, strokeJoin: "round" }), handleRadius != null &&
102
+ return (_jsxs(Group, { children: [gridPath && (_jsx(Path, { path: gridPath, color: PLANE_GRID_COLOR, style: "stroke", strokeWidth: PLANE_GRID_WIDTH, opacity: PLANE_GRID_OPACITY })), _jsx(Path, { path: outlinePath, color: PLANE_COLOR, style: "stroke", strokeWidth: PLANE_OUTLINE_WIDTH * outlineScale, strokeJoin: "round" }), handleRadius != null &&
103
103
  handlePoints.map((p, i) => (_jsxs(Group, { children: [_jsx(Circle, { c: p, r: handleRadius, color: PLANE_COLOR }), _jsx(Circle, { c: p, r: handleRadius, color: "#FFFFFF", style: "stroke", strokeWidth: handleRingWidth })] }, i)))] }));
104
104
  },
105
105
  // Value-compare the bounds rect: AnnotationCanvasSkia derives it fresh each
@@ -111,6 +111,7 @@ export const PlaneElement = memo(({ plane, scaleBounds, showHandles, handleRadiu
111
111
  prev.showHandles === next.showHandles &&
112
112
  prev.handleRadius === next.handleRadius &&
113
113
  prev.handleRingWidth === next.handleRingWidth &&
114
+ prev.outlineScale === next.outlineScale &&
114
115
  prev.scaleBounds.x === next.scaleBounds.x &&
115
116
  prev.scaleBounds.y === next.scaleBounds.y &&
116
117
  prev.scaleBounds.width === next.scaleBounds.width &&
@@ -15,6 +15,7 @@ export type BuildJobCsvInput = {
15
15
  measurementsByGroup: Map<string, Measurement[]>;
16
16
  objectsByGroup: Map<string, ExportObjectRow[]>;
17
17
  formatMeasurementValue: (value: number, device?: string) => string;
18
+ applyGroupFormula?: (group: Group, measurement: Measurement) => number | null;
18
19
  unitPrefs: {
19
20
  defaultUnit?: Units;
20
21
  fractionalTolerance?: FractionalTolerance;
@@ -39,7 +39,7 @@ const userCell = (user) => (user ? [user.firstName, user.lastName].filter(Boolea
39
39
  // trailing "Objects" column (list groups) or extend the form row (form
40
40
  // groups), one skipped column after the existing cells.
41
41
  export function buildJobCsv(input) {
42
- const { scope, activeGroupId, config, groups, sections, formulas, measurementsByGroup, objectsByGroup, formatMeasurementValue, unitPrefs, } = input;
42
+ const { scope, activeGroupId, config, groups, sections, formulas, measurementsByGroup, objectsByGroup, formatMeasurementValue, applyGroupFormula, unitPrefs, } = input;
43
43
  const csvRows = [];
44
44
  let listGroups;
45
45
  let formGroups;
@@ -82,9 +82,13 @@ export function buildJobCsv(input) {
82
82
  headerCells.push(escapeCSVField(''), escapeCSVField('Objects'));
83
83
  }
84
84
  csvRows.push(headerCells.join(','));
85
- // Export rows in the card/table display order (incomplete first, then
86
- // capture time), regardless of the order the caller fetched them in.
87
- const measurements = sortMeasurementsByCompleted(measurementsByGroup.get(group.id) ?? []).filter(measurementPassesFilter);
85
+ // File-scratch measurements (non-null fileId: created inside a CAD/PDF
86
+ // canvas, never saved into the group) live in the same subcollection
87
+ // but are internal — the group cards hide them, so exports must too.
88
+ // Then export rows in the card/table display order (incomplete first,
89
+ // then capture time), regardless of the order the caller fetched them
90
+ // in.
91
+ const measurements = sortMeasurementsByCompleted((measurementsByGroup.get(group.id) ?? []).filter((m) => m.fileId == null)).filter(measurementPassesFilter);
88
92
  // Objects fill a parallel column, one per row from the top; extra rows
89
93
  // are emitted when a group has more objects than measurements.
90
94
  const rowCount = Math.max(measurements.length, objects.length);
@@ -94,12 +98,22 @@ export function buildJobCsv(input) {
94
98
  if (includeField('measurement')) {
95
99
  // Angles and unitless numbers store their value as entered, not as
96
100
  // micrometers — running them through the length formatter would
97
- // render 45° as ~0. Emit them as-is with their own suffix.
98
- row.push(escapeCSVField(measurement
99
- ? isScalarMeasurement(measurement.type)
100
- ? `${measurement.value}${scalarValueSuffix(measurement.type)}`
101
- : formatMeasurementValue(measurement.value, measurement.device)
102
- : ''));
101
+ // render 45° as ~0. Emit them as-is with their own suffix. Group
102
+ // formulas apply to length measurements only, same as the tiles.
103
+ let cell = '';
104
+ if (measurement) {
105
+ if (isScalarMeasurement(measurement.type)) {
106
+ cell = `${measurement.value}${scalarValueSuffix(measurement.type)}`;
107
+ }
108
+ else {
109
+ const formulaValue = applyGroupFormula?.(group, measurement) ?? null;
110
+ cell =
111
+ formulaValue !== null
112
+ ? formatMeasurementValue(formulaValue)
113
+ : formatMeasurementValue(measurement.value, measurement.device);
114
+ }
115
+ }
116
+ row.push(escapeCSVField(cell));
103
117
  }
104
118
  if (includeField('label')) {
105
119
  row.push(escapeCSVField(measurement?.label || ''));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reekon-tools/boldr-utils",
3
- "version": "1.17.2",
3
+ "version": "1.17.4",
4
4
  "description": "Shared utilities for formulas and measurement conversion used in Reekon apps",
5
5
  "author": "REEKON Tools",
6
6
  "license": "MIT",