diff --git a/app/(Minitool_one)/chart_components/BatteryBar.jsx b/app/(Minitool_one)/chart_components/BatteryBar.jsx
new file mode 100644
index 0000000..823936c
--- /dev/null
+++ b/app/(Minitool_one)/chart_components/BatteryBar.jsx
@@ -0,0 +1,78 @@
+import React, { useState } from "react";
+import { Rect, Circle, G } from "react-native-svg";
+import Animated, {
+ useAnimatedReaction,
+ runOnJS,
+} from "react-native-reanimated";
+
+const AnimatedRect = Animated.createAnimatedComponent(Rect);
+const BAR_HEIGHT = 8;
+const DOT_COLOR = "#000";
+const RANGE_HIGHLIGHT_COLOR = "#ff0000";
+const TOUGH_CELL_COLOR = "#33cc33";
+const ALWAYS_READY_COLOR = "#cc00ff";
+const MAX_LIFESPAN = 140;
+const BAR_SPACING = 7;
+
+const BatteryBar = ({
+ item,
+ index,
+ chartWidth,
+ rangeStartX,
+ rangeEndX,
+ tool,
+ dotsOnly,
+ maxLifespan = 140,
+}) => {
+ const yPos = index * (BAR_HEIGHT + BAR_SPACING);
+ const originalColor =
+ item.brand === "Tough Cell" ? TOUGH_CELL_COLOR : ALWAYS_READY_COLOR;
+ const [barColor, setBarColor] = useState(originalColor);
+ const barEndPosition = (item.lifespan / maxLifespan) * chartWidth;
+
+ useAnimatedReaction(
+ () => ({
+ isToolActive: tool,
+ start: rangeStartX.value,
+ end: rangeEndX.value,
+ }),
+ (currentRange) => {
+ "worklet";
+ if (currentRange.isToolActive) {
+ if (
+ barEndPosition >= currentRange.start &&
+ barEndPosition <= currentRange.end
+ ) {
+ runOnJS(setBarColor)(RANGE_HIGHLIGHT_COLOR);
+ } else {
+ runOnJS(setBarColor)(originalColor);
+ }
+ } else {
+ runOnJS(setBarColor)(originalColor);
+ }
+ },
+ [barEndPosition, originalColor, tool]
+ );
+
+ return (
+
+ {!dotsOnly && (
+
+ )}
+
+
+ );
+};
+
+export default BatteryBar;
diff --git a/app/(Minitool_one)/controls/ChartControls.jsx b/app/(Minitool_one)/controls/ChartControls.jsx
new file mode 100644
index 0000000..8c3d1a8
--- /dev/null
+++ b/app/(Minitool_one)/controls/ChartControls.jsx
@@ -0,0 +1,152 @@
+import React, { useState, useCallback } from "react";
+import { View, Text, Switch, Button, StyleSheet } from "react-native";
+
+/**
+ * ChartControls Hook
+ * Encapsulates all chart control logic including sorting, filtering, and tool toggles
+ * Returns state setters and a renderControls() function for rendering
+ */
+const useChartControls = () => {
+ // --- Control state ---
+ const [isSortedBySize, setIsSortedBySize] = useState(false);
+ const [isSortedByColor, setIsSortedByColor] = useState(false);
+ const [hideGreenBars, setHideGreenBars] = useState(false);
+ const [hidePurpleBars, setHidePurpleBars] = useState(false);
+ const [showDotsOnly, setShowDotsOnly] = useState(false);
+ const [valueToolActive, setValueToolActive] = useState(false);
+ const [rangeToolActive, setRangeToolActive] = useState(false);
+
+ // --- Handler functions ---
+ const handleSortBySize = useCallback((isActive) => {
+ setIsSortedBySize(isActive);
+ // When sorting by size, turn off color sort
+ if (isActive) {
+ setIsSortedByColor(false);
+ }
+ }, []);
+
+ const handleSortByColor = useCallback((isActive) => {
+ setIsSortedByColor(isActive);
+ // When sorting by color, turn off size sort
+ if (isActive) {
+ setIsSortedBySize(false);
+ }
+ }, []);
+
+ const handleValueTool = useCallback((isActive) => {
+ setValueToolActive(isActive);
+ }, []);
+
+ const handleRangeTool = useCallback((isActive) => {
+ setRangeToolActive(isActive);
+ }, []);
+
+ const handleHideGreenBars = useCallback((isActive) => {
+ setHideGreenBars(isActive);
+ }, []);
+
+ const handleHidePurpleBars = useCallback((isActive) => {
+ setHidePurpleBars(isActive);
+ }, []);
+
+ const handleShowDotsOnly = useCallback((isActive) => {
+ setShowDotsOnly(isActive);
+ }, []);
+
+ // --- Render component with all controls ---
+ const renderControls = useCallback(
+ () => (
+
+ {/* --- Tool toggles --- */}
+
+ Value tool
+
+ Range tool
+
+
+
+ {/* --- Visibility filters --- */}
+
+ Hide Green Bars
+
+ Hide Purple Bars
+
+ Show Dots Only
+
+
+
+ {/* --- Sorting controllers --- */}
+
+
+
+ ),
+ [
+ valueToolActive,
+ rangeToolActive,
+ hideGreenBars,
+ hidePurpleBars,
+ showDotsOnly,
+ isSortedByColor,
+ isSortedBySize,
+ ]
+ );
+
+ return {
+ // State
+ isSortedBySize,
+ isSortedByColor,
+ hideGreenBars,
+ hidePurpleBars,
+ showDotsOnly,
+ valueToolActive,
+ rangeToolActive,
+
+ // Setters
+ setIsSortedBySize,
+ setIsSortedByColor,
+ setHideGreenBars,
+ setHidePurpleBars,
+ setShowDotsOnly,
+ setValueToolActive,
+ setRangeToolActive,
+
+ // Handlers
+ handleSortBySize,
+ handleSortByColor,
+ handleValueTool,
+ handleRangeTool,
+ handleHideGreenBars,
+ handleHidePurpleBars,
+ handleShowDotsOnly,
+
+ // Render function
+ renderControls,
+ };
+};
+
+const styles = StyleSheet.create({
+ controlsContainer: {
+ flexDirection: "row",
+ justifyContent: "space-around",
+ width: "100%",
+ marginBottom: 10,
+ paddingVertical: 10,
+ borderTopWidth: 1,
+ borderBottomWidth: 1,
+ borderColor: "#e0e0e0",
+ },
+ switchControl: {
+ flexDirection: "column",
+ justifyContent: "center",
+ },
+});
+
+export default useChartControls;
diff --git a/app/(Minitool_one)/minitool_1.jsx b/app/(Minitool_one)/minitool_1.jsx
index 85b068d..a702cb6 100644
--- a/app/(Minitool_one)/minitool_1.jsx
+++ b/app/(Minitool_one)/minitool_1.jsx
@@ -30,7 +30,12 @@ import Animated, {
} from "react-native-reanimated";
import Svg, { Rect, Circle, Line, G, Text as SvgText } from "react-native-svg";
import initialBatteryData from "../../data/batteryScenario_set.json";
-import BatteryBar from "./minitool_one_components/BatteryBar";
+import BatteryBar from "./chart_components/BatteryBar";
+import useValueTool from "./tools/ValueTool";
+import useRangeTool from "./tools/RangeTool";
+import useChartControls from "./controls/ChartControls";
+import useDataGenerationModal from "./modals/DataGenerationModal";
+import useBarGenerationModal from "./modals/BarGenerationModal";
const AnimatedG = Animated.createAnimatedComponent(G);
const AnimatedRect = Animated.createAnimatedComponent(Rect);
@@ -58,341 +63,167 @@ const MOBILE_VALUE_STEP = 26;
const WEB_VALUE_STEP = 14;
const PADDING = 0;
const Y_AXIS_WIDTH = 30;
-const BAR_HEIGHT = 8;
-const BAR_SPACING = 7;
+const BAR_HEIGHT = 6;
+const BAR_SPACING = 4;
const X_AXIS_HEIGHT = 20;
const TOOL_LABEL_OFFSET_Y = 25;
const RANGE_LABEL_OFFSET_Y = 15;
const TOP_BUFFER = RANGE_LABEL_OFFSET_Y + 10;
+const SIDEBAR_WIDTH = 120;
+
const { width, height } = Dimensions.get("window");
const Minitool_1 = () => {
const [currentBatteryData, setCurrentBatteryData] =
useState(initialBatteryData);
const [displayedData, setDisplayedData] = useState(initialBatteryData);
- const [isSortedBySize, setIsSortedBySize] = useState(false);
- const [isSortedByColor, setIsSortedByColor] = useState(false);
const [toolValue, setToolValue] = useState(80.0);
const [rangeCount, setRangeCount] = useState(0);
- const [valueToolActive, setValueToolActive] = useState(false);
- const [rangeToolActive, setRangeToolActive] = useState(false);
- const [isModalVisible, setIsModalVisible] = useState(false);
- const [minLifespanInput, setMinLifespanInput] = useState("40");
- const [maxLifespanInput, setMaxLifespanInput] = useState("120");
- const [toughCellCountInput, setToughCellCountInput] = useState("10");
- const [alwaysReadyCountInput, setAlwaysReadyCountInput] = useState("10");
- const [isAddBarModalVisible, setIsAddBarModalVisible] = useState(false);
- const [newBarLifespan, setNewBarLifespan] = useState("100");
- const [newBarBrand, setNewBarBrand] = useState("Tough Cell");
const [isHelpVisible, setIsHelpVisible] = useState(false);
- const chartHeight = 20 * (BAR_HEIGHT + BAR_SPACING);
+ // --- Chart Controls Hook (extracted to useChartControls hook) ---
+ const chartControls = useChartControls();
+
+ // --- Calculate stats for displayed data ---
+ const visibleBars = displayedData.filter((item) => item.visible);
+ const minLifespan =
+ visibleBars.length > 0
+ ? Math.min(...visibleBars.map((item) => item.lifespan))
+ : 0;
+ const maxLifespan =
+ visibleBars.length > 0
+ ? Math.max(...visibleBars.map((item) => item.lifespan))
+ : 0;
+ const barCount = 20;
+
+ const chartHeight = Math.max(10, barCount * (BAR_HEIGHT + 2 * BAR_SPACING));
+ console.log("Chart Height:", chartHeight);
const SVG_HEIGHT = chartHeight + X_AXIS_HEIGHT + TOP_BUFFER;
- const SVG_WIDTH = width - PADDING * 2;
+ const SVG_WIDTH = width - PADDING * 2 - SIDEBAR_WIDTH;
const chartWidth =
SVG_WIDTH - Y_AXIS_WIDTH > 0 ? SVG_WIDTH - Y_AXIS_WIDTH : 1;
- // --- Value Tool Gesture Logic ---
+ // --- Initial values for tools ---
const initialTranslateX = (80.0 / MAX_LIFESPAN) * chartWidth;
- const translateX = useSharedValue(initialTranslateX);
- const context = useSharedValue({ x: 0 });
- const panGesture = Gesture.Pan()
- .onStart(() => {
- context.value = { x: translateX.value };
- })
- .onUpdate((event) => {
- translateX.value = clamp(
- event.translationX + context.value.x,
- 0,
- chartWidth
- );
- });
-
- // --- Range Tool Gesture Logic ---
const initialRangeStartX = (102 / MAX_LIFESPAN) * chartWidth;
const initialRangeEndX = (126 / MAX_LIFESPAN) * chartWidth;
- const rangeStartX = useSharedValue(initialRangeStartX);
- const rangeEndX = useSharedValue(initialRangeEndX);
- const rangeContext = useSharedValue({ start: 0, end: 0 });
-
- const movePanGesture = Gesture.Pan()
- .onStart(() => {
- rangeContext.value = { start: rangeStartX.value, end: rangeEndX.value };
- })
- .onUpdate((event) => {
- const rangeWidth = rangeContext.value.end - rangeContext.value.start;
- const newStart = clamp(
- rangeContext.value.start + event.translationX,
- 0,
- chartWidth - rangeWidth
- );
- rangeStartX.value = newStart;
- rangeEndX.value = newStart + rangeWidth;
- });
-
- const leftHandlePanGesture = Gesture.Pan()
- .onStart(() => {
- rangeContext.value = { start: rangeStartX.value, end: rangeEndX.value };
- })
- .onUpdate((event) => {
- rangeStartX.value = clamp(
- rangeContext.value.start + event.translationX,
- 0,
- rangeEndX.value - RANGE_HANDLE_SIZE
- );
- });
- const rightHandlePanGesture = Gesture.Pan()
- .onStart(() => {
- rangeContext.value = { start: rangeStartX.value, end: rangeEndX.value };
- })
- .onUpdate((event) => {
- rangeEndX.value = clamp(
- rangeContext.value.end + event.translationX,
- rangeStartX.value + RANGE_HANDLE_SIZE,
- chartWidth
- );
- });
-
- // --- Animated Props for lines of the tools ---
- const animatedToolProps = useAnimatedProps(() => ({
- x: translateX.value - 7.5,
- }));
- const animatedValueLineProps = useAnimatedProps(() => ({
- x1: translateX.value,
- x2: translateX.value,
- }));
- const animatedRangeRectProps = useAnimatedProps(() => ({
- x: rangeStartX.value,
- width: rangeEndX.value - rangeStartX.value,
- }));
- const animatedRangeLeftLineProps = useAnimatedProps(() => ({
- x1: rangeStartX.value,
- x2: rangeStartX.value,
- }));
- const animatedRangeRightLineProps = useAnimatedProps(() => ({
- x1: rangeEndX.value,
- x2: rangeEndX.value,
- }));
- const animatedLeftHandleProps = useAnimatedProps(() => ({
- x: rangeStartX.value - RANGE_HANDLE_SIZE / 2,
- }));
- const animatedRightHandleProps = useAnimatedProps(() => ({
- x: rangeEndX.value - RANGE_HANDLE_SIZE / 2,
- }));
- const animatedMoveHandleProps = useAnimatedProps(() => ({
- x: rangeStartX.value,
- width: Math.abs(rangeStartX.value - rangeEndX.value),
- }));
-
- // --- Animations for the labels ---
- const animatedLabelStyle = useAnimatedStyle(() => ({
- transform: [{ translateX: translateX.value }],
- opacity: withTiming(valueToolActive ? 1 : 0),
- }));
+ // --- Value Tool Gesture Logic (extracted to useValueTool hook) ---
+ const valueTool = useValueTool({
+ isActive: chartControls.valueToolActive,
+ onActiveChange: chartControls.setValueToolActive,
+ onValueChange: setToolValue,
+ chartWidth,
+ chartHeight,
+ maxLifespan: MAX_LIFESPAN,
+ toolValue,
+ toolColor: TOOL_COLOR,
+ X_AXIS_HEIGHT: X_AXIS_HEIGHT,
+ });
- const animatedRangeLabelStyle = useAnimatedStyle(() => ({
- transform: [{ translateX: (rangeStartX.value + rangeEndX.value) / 2 }],
- opacity: withTiming(rangeToolActive ? 1 : 0),
- }));
+ // --- Range Tool Gesture Logic (extracted to useRangeTool hook) ---
+ const rangeTool = useRangeTool({
+ isActive: chartControls.rangeToolActive,
+ onActiveChange: chartControls.setRangeToolActive,
+ onCountChange: setRangeCount,
+ chartWidth,
+ chartHeight,
+ maxLifespan: MAX_LIFESPAN,
+ initialStartValue: 102,
+ initialEndValue: 126,
+ rangeHandleSize: RANGE_HANDLE_SIZE,
+ rangeToolColor: RANGE_TOOL_COLOR,
+ displayedData,
+ X_AXIS_HEIGHT: X_AXIS_HEIGHT,
+ });
- // --- Animations for disappearing and appearing tools ---
- const valueToolContainerAnimatedProps = useAnimatedProps(() => {
- return { opacity: withTiming(valueToolActive ? 1 : 0) };
+ // --- Data Generation Modal Hook (initialized after tools) ---
+ const dataGenerationModal = useDataGenerationModal({
+ onDataGenerated: (data) => {
+ setCurrentBatteryData(data);
+ chartControls.setIsSortedByColor(false);
+ chartControls.setIsSortedBySize(false);
+ },
+ onClose: () => {
+ valueTool.translateX.value = initialTranslateX;
+ rangeTool.rangeStartX.value = initialRangeStartX;
+ rangeTool.rangeEndX.value = initialRangeEndX;
+ },
});
- const rangeToolContainerAnimatedProps = useAnimatedProps(() => {
- return { opacity: withTiming(rangeToolActive ? 1 : 0) };
+ // --- Bar Generation Modal Hook ---
+ const barGenerationModal = useBarGenerationModal({
+ onBarAdded: (newBar) => {
+ setCurrentBatteryData([...currentBatteryData, newBar]);
+ },
+ onClose: () => {
+ valueTool.translateX.value = initialTranslateX;
+ rangeTool.rangeStartX.value = initialRangeStartX;
+ rangeTool.rangeEndX.value = initialRangeEndX;
+ },
+ currentBarCount: currentBatteryData.length,
+ MAX_BAR_COUNT: MAX_BAR_COUNT,
});
+ // --- Animated Props for lines of the tools (range tool moved to RangeTool component) ---
+
+ // --- Animations for the labels (moved to RangeTool component) ---
+
// --- Function to handle value tool ---
useAnimatedReaction(
- () => translateX.value,
- (currentValue) =>
- runOnJS(setToolValue)((currentValue / chartWidth) * MAX_LIFESPAN),
- [chartWidth]
- );
- // --- Function to handle range tool ---
- useAnimatedReaction(
- () => ({ start: rangeStartX.value, end: rangeEndX.value }),
- (currentRange, previousRange) => {
- if (
- currentRange.start !== previousRange?.start ||
- currentRange.end !== previousRange?.end
- ) {
- const minLifespan = (currentRange.start / chartWidth) * MAX_LIFESPAN;
- const maxLifespan = (currentRange.end / chartWidth) * MAX_LIFESPAN;
- const count = displayedData.filter(
- (item) => item.lifespan >= minLifespan && item.lifespan <= maxLifespan
- ).length;
- runOnJS(setRangeCount)(count);
- }
+ () => valueTool.translateX.value,
+ () => {
+ // Value update is handled inside ValueTool component
},
- [chartWidth, displayedData]
+ [chartWidth]
);
+ // --- Function to handle range tool (moved to RangeTool component) ---
- // --- Sorting handlers ---
+ // --- Sorting and filtering handlers ---
useEffect(() => {
let dataToDisplay = [...currentBatteryData];
- if (isSortedBySize) {
+
+ // Apply sorting
+ if (chartControls.isSortedBySize) {
dataToDisplay.sort((a, b) => a.lifespan - b.lifespan);
- } else if (isSortedByColor) {
+ } else if (chartControls.isSortedByColor) {
dataToDisplay.sort((a, b) => a.brand.localeCompare(b.brand));
}
- setDisplayedData(dataToDisplay);
- }, [currentBatteryData, isSortedBySize, isSortedByColor]);
- const handleSortBySize = (isActive) => {
- setIsSortedBySize(isActive);
- if (isActive) {
- setIsSortedByColor(false);
- }
- };
+ // Mark which items should be visible (keep them in array with visibility flag)
+ dataToDisplay = dataToDisplay.map((item) => ({
+ ...item,
+ visible:
+ !(chartControls.hideGreenBars && item.brand === "Tough Cell") &&
+ !(chartControls.hidePurpleBars && item.brand === "Always Ready"),
+ }));
- const handleSortByColor = (isActive) => {
- setIsSortedByColor(isActive);
- if (isActive) {
- setIsSortedBySize(false);
- }
- };
-
- // --- Simplified toggle handlers ---
- const handleValueTool = (isActive) => {
- setValueToolActive(isActive);
- };
- const handleRangeTool = (isActive) => {
- setRangeToolActive(isActive);
- };
+ setDisplayedData(dataToDisplay);
+ }, [
+ currentBatteryData,
+ chartControls.isSortedBySize,
+ chartControls.isSortedByColor,
+ chartControls.hideGreenBars,
+ chartControls.hidePurpleBars,
+ ]);
// --- Handlers for Modal(Pop up window which allows to generate random data) ---
- const handleGenerateDataButton = () => {
- setRangeToolActive(false);
- setValueToolActive(false);
- setIsModalVisible(true);
- };
-
- const handleGenerateData = () => {
- translateX.value = initialTranslateX;
- rangeStartX.value = initialRangeStartX;
- rangeEndX.value = initialRangeEndX;
- const min = parseInt(minLifespanInput, 10);
- const max = parseInt(maxLifespanInput, 10);
- const toughCellCount = parseInt(toughCellCountInput, 10);
- const alwaysReadyCount = parseInt(alwaysReadyCountInput, 10);
-
- if (
- isNaN(min) ||
- isNaN(max) ||
- isNaN(toughCellCount) ||
- isNaN(alwaysReadyCount) ||
- min >= max ||
- min < 0 ||
- toughCellCount < 0 ||
- alwaysReadyCount < 0 ||
- max > MAX_LIFESPAN
- ) {
- if (Platform.OS === "web") {
- alert(
- `Invalid Input. Please check your values. Min Lifespan must be less than Max Lifespan, Max Lifespan must be less than ${MAX_LIFESPAN} `
- );
- } else {
- Alert.alert(
- "Invalid Input",
- `Please check your values. Min Lifespan must be less than Max Lifespan, Max Lifespan must be less than ${MAX_LIFESPAN}`
- );
- }
- return;
- }
- if (
- toughCellCount < MIN_BATTERY_COUNT_VALUE ||
- toughCellCount > MAX_BATTERY_COUNT_VALUE
- ) {
- if (Platform.OS === "web") {
- alert(
- `Invalid Input. Please check your values. The number of batteries of the company ToughCell must be greater than 0 and less than ${MAX_BATTERY_COUNT_VALUE}`
- );
- } else {
- Alert.alert(
- "Invalid Input",
- `Please check your values. The number of batteries of the company ToughCell must be greater than 0 and less than ${MAX_BATTERY_COUNT_VALUE}`
- );
- }
- return;
- }
-
- if (
- alwaysReadyCount < MIN_BATTERY_COUNT_VALUE ||
- alwaysReadyCount > MAX_BATTERY_COUNT_VALUE
- ) {
- if (Platform.OS == "web") {
- alert(
- `Invalid Input. Please check your values. The number of batteries of the company AlwaysReady must be greater than 0 and less than ${MAX_BATTERY_COUNT_VALUE}`
- );
- } else {
- Alert.alert(
- "Invalid Input",
- `Please check your values. The number of batteries of the company AlwaysReady must be greater than 0 and less than ${MAX_BATTERY_COUNT_VALUE}`
- );
- }
- return;
- }
-
- const newData = [];
- const getRandomLifespan = (min, max) =>
- Math.floor(Math.random() * (max - min + 1)) + min;
-
- for (let i = 0; i < toughCellCount; i++) {
- newData.push({
- brand: "Tough Cell",
- lifespan: getRandomLifespan(min, max),
- });
- }
- for (let i = 0; i < alwaysReadyCount; i++) {
- newData.push({
- brand: "Always Ready",
- lifespan: getRandomLifespan(min, max),
- });
- }
-
- setCurrentBatteryData(newData);
- setIsSortedByColor(false);
- setIsSortedBySize(false);
- setIsModalVisible(false);
- };
-
- const handleCancelbutton = () => {
- translateX.value = initialTranslateX;
- rangeStartX.value = initialRangeStartX;
- rangeEndX.value = initialRangeEndX;
- setIsModalVisible(false);
- };
+ // Now handled by useDataGenerationModal hook - open via dataGenerationModal.handleOpenModal()
// --- Reset data to the initial one(which was diaplayed first) ---
const handleResetData = () => {
setCurrentBatteryData(initialBatteryData);
- setIsSortedByColor(false);
- setIsSortedBySize(false);
+ chartControls.setIsSortedByColor(false);
+ chartControls.setIsSortedBySize(false);
};
// --- Handlers for Adding/Removing single bars ---
const handleAddBarButtonPress = () => {
- translateX.value = initialTranslateX;
- rangeStartX.value = initialRangeStartX;
- rangeEndX.value = initialRangeEndX;
- setRangeToolActive(false);
- setValueToolActive(false);
- if (currentBatteryData.length >= MAX_BAR_COUNT) {
- Alert.alert(
- "Limit Reached",
- `You cannot add more than ${MAX_BAR_COUNT} batteries.`
- );
- return;
- }
- setNewBarLifespan("100");
- setNewBarBrand("Tough Cell");
- setIsAddBarModalVisible(true);
+ chartControls.setRangeToolActive(false);
+ chartControls.setValueToolActive(false);
+ barGenerationModal.handleOpenModal();
};
const handleRemoveLastBar = () => {
@@ -404,28 +235,7 @@ const Minitool_1 = () => {
setCurrentBatteryData(newData);
};
- const handleConfirmAddBar = () => {
- const lifespan = parseInt(newBarLifespan, 10);
- if (isNaN(lifespan) || lifespan < MIN_LIFESPAN || lifespan > MAX_LIFESPAN) {
- Alert.alert(
- "Invalid Lifespan",
- `Please enter a number between ${MIN_LIFESPAN} and ${MAX_LIFESPAN}.`
- );
- return;
- }
-
- const newBar = {
- brand: newBarBrand,
- lifespan: lifespan,
- };
-
- setCurrentBatteryData([...currentBatteryData, newBar]);
- setIsAddBarModalVisible(false);
- };
-
- const handleCancelAddBar = () => {
- setIsAddBarModalVisible(false);
- };
+ // Handlers for add bar modal are now in useBarGenerationModal hook
return (
@@ -514,328 +324,177 @@ const Minitool_1 = () => {
)}
- {/* --- Sorting controllers --- */}
-
-
- Sort by Color
-
-
-
- Sort by Size
-
-
-
-
{/* Bar chart with tools*/}
- {!isModalVisible && !isAddBarModalVisible && (
-
- {/* --- Render the label of value tool --- */}
-
- {toolValue.toFixed(1)}
-
-
- {/* --- Render the label of range tool --- */}
-
- count: {rangeCount}
-
-
- {/* --- Whole bar chart */}
-
-
- )}
+
+
+
+ {/* --- Stats Sidebar --- */}
+
+
+ Min:
+
+
+ {minLifespan}
+
+
+
+ Max:
+
+
+ {maxLifespan}
+
+
+
+ Amount:
+
+ {barCount}
+
+
+ )}
Life Span (hours)
- {/* --- Toogle controllers --- */}
-
-
- Value tool
-
-
-
- Range tool
-
-
-
+ {/* --- Chart Controls (rendered by useChartControls hook) --- */}
+ {chartControls.renderControls()}
{/* --- Buttons for generating new DATA --- */}
@@ -859,131 +518,21 @@ const Minitool_1 = () => {
{
+ chartControls.setRangeToolActive(false);
+ chartControls.setValueToolActive(false);
+ dataGenerationModal.handleOpenModal();
+ }}
color="#47d147"
/>
- {/* --- Pop-up window for generating data set --- */}
- setIsModalVisible(!isModalVisible)}
- >
-
-
- Generate New Data
- {/* --- Text inputs --- */}
-
- Min Lifespan:
-
-
-
-
- Max Lifespan:
-
-
-
-
- Tough Cell Count:
-
-
-
-
- Always Ready Count:
-
-
-
- {/* --- Buttons Cancel(cancel generation of data) and Generate(generate new one) */}
-
-
-
-
-
-
-
-
- {/* --- NEW: Pop-up window for adding a single bar --- */}
- setIsAddBarModalVisible(false)}
- >
-
-
- Add a New Battery
-
-
- Lifespan (1-130):
-
-
+ {/* --- Pop-up window for generating data set (now via hook) --- */}
+ {dataGenerationModal.renderModal()}
- Brand:
-
- setNewBarBrand("Tough Cell")}
- style={[
- styles.brandButton,
- newBarBrand === "Tough Cell" && styles.brandButtonSelected,
- ]}
- >
- Tough Cell
-
- setNewBarBrand("Always Ready")}
- style={[
- styles.brandButton,
- newBarBrand === "Always Ready" &&
- styles.brandButtonSelected,
- ]}
- >
- Always Ready
-
-
-
-
-
-
-
-
-
-
+ {/* --- Pop-up window for adding a single bar (now via hook) --- */}
+ {barGenerationModal.renderModal()}
);
@@ -1070,19 +619,6 @@ const styles = StyleSheet.create({
fontWeight: "bold",
color: "#1f2937",
},
- controlsContainer: {
- flexDirection: "row",
- justifyContent: "space-around",
- width: "100%",
- marginBottom: 10,
- paddingVertical: 10,
- borderTopWidth: 1,
- borderBottomWidth: 1,
- borderColor: "#e0e0e0",
- },
- switchControl: {
- alignItems: "center",
- },
chartContainer: {
width: "100%",
marginTop: 20,
diff --git a/app/(Minitool_one)/modals/BarGenerationModal.jsx b/app/(Minitool_one)/modals/BarGenerationModal.jsx
new file mode 100644
index 0000000..e45f719
--- /dev/null
+++ b/app/(Minitool_one)/modals/BarGenerationModal.jsx
@@ -0,0 +1,251 @@
+import React, { useState, useCallback } from "react";
+import {
+ Modal,
+ View,
+ Text,
+ TextInput,
+ Button,
+ StyleSheet,
+ TouchableOpacity,
+ Alert,
+ Platform,
+} from "react-native";
+
+/**
+ * BarGenerationModal Hook
+ * Encapsulates all add bar modal logic including state management and validation
+ * Returns state and handlers for modal management
+ */
+const useBarGenerationModal = ({
+ onBarAdded,
+ onClose,
+ initialLifespan = "100",
+ initialBrand = "Tough Cell",
+ // Configuration constants
+ MAX_LIFESPAN = 130,
+ MIN_LIFESPAN = 1,
+ MAX_BAR_COUNT = 20,
+ currentBarCount = 0,
+}) => {
+ const [isModalVisible, setIsModalVisible] = useState(false);
+ const [barLifespanInput, setBarLifespanInput] = useState(initialLifespan);
+ const [barBrandInput, setBarBrandInput] = useState(initialBrand);
+
+ // --- Handler for opening modal ---
+ const handleOpenModal = useCallback(() => {
+ setBarLifespanInput(initialLifespan);
+ setBarBrandInput(initialBrand);
+
+ // Check bar count limit before opening
+ if (currentBarCount >= MAX_BAR_COUNT) {
+ const message = `You cannot add more than ${MAX_BAR_COUNT} batteries.`;
+ if (Platform.OS === "web") {
+ alert("Limit Reached\n" + message);
+ } else {
+ Alert.alert("Limit Reached", message);
+ }
+ return;
+ }
+
+ setIsModalVisible(true);
+ }, [initialLifespan, initialBrand, currentBarCount, MAX_BAR_COUNT]);
+
+ // --- Handler for canceling modal ---
+ const handleCancelModal = useCallback(() => {
+ setIsModalVisible(false);
+ if (onClose) {
+ onClose();
+ }
+ }, [onClose]);
+
+ // --- Handler for adding bar ---
+ const handleAddBar = useCallback(() => {
+ const lifespan = parseInt(barLifespanInput, 10);
+
+ // Validation checks
+ if (isNaN(lifespan) || lifespan < MIN_LIFESPAN || lifespan > MAX_LIFESPAN) {
+ const message = `Please enter a number between ${MIN_LIFESPAN} and ${MAX_LIFESPAN}.`;
+ if (Platform.OS === "web") {
+ alert("Invalid Lifespan\n" + message);
+ } else {
+ Alert.alert("Invalid Lifespan", message);
+ }
+ return;
+ }
+
+ // Create new bar object
+ const newBar = {
+ brand: barBrandInput,
+ lifespan: lifespan,
+ };
+
+ // Callback to parent with new bar
+ if (onBarAdded) {
+ onBarAdded(newBar);
+ }
+
+ setIsModalVisible(false);
+ }, [barLifespanInput, barBrandInput, MIN_LIFESPAN, MAX_LIFESPAN, onBarAdded]);
+
+ // --- Render modal component ---
+ const renderModal = useCallback(
+ () => (
+
+
+
+ Add a New Battery
+
+
+ Lifespan (1-130):
+
+
+
+ Brand:
+
+ setBarBrandInput("Tough Cell")}
+ style={[
+ styles.brandButton,
+ barBrandInput === "Tough Cell" && styles.brandButtonSelected,
+ ]}
+ >
+ Tough Cell
+
+ setBarBrandInput("Always Ready")}
+ style={[
+ styles.brandButton,
+ barBrandInput === "Always Ready" &&
+ styles.brandButtonSelected,
+ ]}
+ >
+ Always Ready
+
+
+
+
+
+
+
+
+
+
+ ),
+ [
+ isModalVisible,
+ barLifespanInput,
+ barBrandInput,
+ handleCancelModal,
+ handleAddBar,
+ ]
+ );
+
+ return {
+ // State
+ isModalVisible,
+ barLifespanInput,
+ barBrandInput,
+
+ // State setters
+ setIsModalVisible,
+ setBarLifespanInput,
+ setBarBrandInput,
+
+ // Handlers
+ handleOpenModal,
+ handleCancelModal,
+ handleAddBar,
+
+ // Render function
+ renderModal,
+ };
+};
+
+const styles = StyleSheet.create({
+ modalCenteredView: {
+ flex: 1,
+ justifyContent: "center",
+ alignItems: "center",
+ backgroundColor: "#e5e7eb",
+ },
+ modalView: {
+ margin: 20,
+ backgroundColor: "white",
+ borderRadius: 20,
+ padding: 25,
+ alignItems: "center",
+ shadowColor: "#000",
+ shadowOffset: { width: 0, height: 2 },
+ shadowOpacity: 0.25,
+ shadowRadius: 4,
+ elevation: 5,
+ width: "90%",
+ maxWidth: 400,
+ },
+ modalText: {
+ marginBottom: 20,
+ textAlign: "center",
+ fontSize: 18,
+ fontWeight: "bold",
+ },
+ inputRow: {
+ flexDirection: "row",
+ alignItems: "center",
+ marginBottom: 12,
+ width: "100%",
+ },
+ inputLabel: {
+ flex: 2,
+ fontSize: 14,
+ marginRight: 10,
+ fontWeight: "500",
+ },
+ input: {
+ flex: 1.5,
+ height: 40,
+ borderWidth: 1,
+ borderColor: "#ccc",
+ padding: 10,
+ borderRadius: 5,
+ textAlign: "center",
+ },
+ brandSelectorContainer: {
+ flexDirection: "row",
+ justifyContent: "space-around",
+ width: "100%",
+ marginVertical: 15,
+ },
+ brandButton: {
+ paddingVertical: 10,
+ paddingHorizontal: 20,
+ borderRadius: 8,
+ borderWidth: 2,
+ borderColor: "#ddd",
+ },
+ brandButtonSelected: {
+ borderColor: "#007AFF",
+ backgroundColor: "#e7f3ff",
+ },
+ brandButtonText: {
+ fontSize: 14,
+ fontWeight: "bold",
+ },
+ modalButtonContainer: {
+ flexDirection: "row",
+ justifyContent: "space-around",
+ width: "100%",
+ marginTop: 20,
+ },
+});
+
+export default useBarGenerationModal;
diff --git a/app/(Minitool_one)/modals/DataGenerationModal.jsx b/app/(Minitool_one)/modals/DataGenerationModal.jsx
new file mode 100644
index 0000000..ea03c13
--- /dev/null
+++ b/app/(Minitool_one)/modals/DataGenerationModal.jsx
@@ -0,0 +1,297 @@
+import React, { useState, useCallback } from "react";
+import {
+ Modal,
+ View,
+ Text,
+ TextInput,
+ Button,
+ StyleSheet,
+ Platform,
+ Alert,
+} from "react-native";
+
+/**
+ * DataGenerationModal Hook
+ * Encapsulates all data generation modal logic including state management and validation
+ * Returns state and handlers for modal management
+ */
+const useDataGenerationModal = ({
+ onDataGenerated,
+ onClose,
+ initialMinLifespan = "40",
+ initialMaxLifespan = "120",
+ initialToughCellCount = "10",
+ initialAlwaysReadyCount = "10",
+ // Configuration constants
+ MAX_LIFESPAN = 130,
+ MAX_BATTERY_COUNT_VALUE = 10,
+ MIN_BATTERY_COUNT_VALUE = 1,
+}) => {
+ const [isModalVisible, setIsModalVisible] = useState(false);
+ const [minLifespanInput, setMinLifespanInput] = useState(initialMinLifespan);
+ const [maxLifespanInput, setMaxLifespanInput] = useState(initialMaxLifespan);
+ const [toughCellCountInput, setToughCellCountInput] = useState(
+ initialToughCellCount
+ );
+ const [alwaysReadyCountInput, setAlwaysReadyCountInput] = useState(
+ initialAlwaysReadyCount
+ );
+
+ // --- Handler for opening modal ---
+ const handleOpenModal = useCallback(() => {
+ setIsModalVisible(true);
+ }, []);
+
+ // --- Handler for canceling modal ---
+ const handleCancelModal = useCallback(() => {
+ setIsModalVisible(false);
+ if (onClose) {
+ onClose();
+ }
+ }, [onClose]);
+
+ // --- Handler for generating data ---
+ const handleGenerateData = useCallback(() => {
+ const min = parseInt(minLifespanInput, 10);
+ const max = parseInt(maxLifespanInput, 10);
+ const toughCellCount = parseInt(toughCellCountInput, 10);
+ const alwaysReadyCount = parseInt(alwaysReadyCountInput, 10);
+
+ // Validation checks
+ if (
+ isNaN(min) ||
+ isNaN(max) ||
+ isNaN(toughCellCount) ||
+ isNaN(alwaysReadyCount) ||
+ min >= max ||
+ min < 0 ||
+ toughCellCount < 0 ||
+ alwaysReadyCount < 0 ||
+ max > MAX_LIFESPAN
+ ) {
+ const message = `Invalid Input. Please check your values. Min Lifespan must be less than Max Lifespan, Max Lifespan must be less than ${MAX_LIFESPAN}`;
+ if (Platform.OS === "web") {
+ alert(message);
+ } else {
+ Alert.alert("Invalid Input", message);
+ }
+ return;
+ }
+
+ if (
+ toughCellCount < MIN_BATTERY_COUNT_VALUE ||
+ toughCellCount > MAX_BATTERY_COUNT_VALUE
+ ) {
+ const message = `Invalid Input. The number of batteries of the company ToughCell must be greater than 0 and less than ${MAX_BATTERY_COUNT_VALUE}`;
+ if (Platform.OS === "web") {
+ alert(message);
+ } else {
+ Alert.alert("Invalid Input", message);
+ }
+ return;
+ }
+
+ if (
+ alwaysReadyCount < MIN_BATTERY_COUNT_VALUE ||
+ alwaysReadyCount > MAX_BATTERY_COUNT_VALUE
+ ) {
+ const message = `Invalid Input. The number of batteries of the company AlwaysReady must be greater than 0 and less than ${MAX_BATTERY_COUNT_VALUE}`;
+ if (Platform.OS === "web") {
+ alert(message);
+ } else {
+ Alert.alert("Invalid Input", message);
+ }
+ return;
+ }
+
+ // Generate data
+ const newData = [];
+ const getRandomLifespan = (minVal, maxVal) =>
+ Math.floor(Math.random() * (maxVal - minVal + 1)) + minVal;
+
+ for (let i = 0; i < toughCellCount; i++) {
+ newData.push({
+ brand: "Tough Cell",
+ lifespan: getRandomLifespan(min, max),
+ });
+ }
+ for (let i = 0; i < alwaysReadyCount; i++) {
+ newData.push({
+ brand: "Always Ready",
+ lifespan: getRandomLifespan(min, max),
+ });
+ }
+
+ // Callback to parent with generated data
+ if (onDataGenerated) {
+ onDataGenerated(newData);
+ }
+
+ setIsModalVisible(false);
+ }, [
+ minLifespanInput,
+ maxLifespanInput,
+ toughCellCountInput,
+ alwaysReadyCountInput,
+ MAX_LIFESPAN,
+ MAX_BATTERY_COUNT_VALUE,
+ MIN_BATTERY_COUNT_VALUE,
+ onDataGenerated,
+ ]);
+
+ // --- Render modal component ---
+ const renderModal = useCallback(
+ () => (
+
+
+
+ Generate New Data
+
+ {/* --- Text inputs --- */}
+
+ Min Lifespan:
+
+
+
+
+ Max Lifespan:
+
+
+
+
+ Tough Cell Count:
+
+
+
+
+ Always Ready Count:
+
+
+
+ {/* --- Buttons Cancel and Generate --- */}
+
+
+
+
+
+
+
+ ),
+ [
+ isModalVisible,
+ minLifespanInput,
+ maxLifespanInput,
+ toughCellCountInput,
+ alwaysReadyCountInput,
+ handleCancelModal,
+ handleGenerateData,
+ ]
+ );
+
+ return {
+ // State
+ isModalVisible,
+ minLifespanInput,
+ maxLifespanInput,
+ toughCellCountInput,
+ alwaysReadyCountInput,
+
+ // State setters
+ setIsModalVisible,
+ setMinLifespanInput,
+ setMaxLifespanInput,
+ setToughCellCountInput,
+ setAlwaysReadyCountInput,
+
+ // Handlers
+ handleOpenModal,
+ handleCancelModal,
+ handleGenerateData,
+
+ // Render function
+ renderModal,
+ };
+};
+
+const styles = StyleSheet.create({
+ modalCenteredView: {
+ flex: 1,
+ justifyContent: "center",
+ alignItems: "center",
+ backgroundColor: "#e5e7eb",
+ },
+ modalView: {
+ margin: 20,
+ backgroundColor: "white",
+ borderRadius: 20,
+ padding: 25,
+ alignItems: "center",
+ shadowColor: "#000",
+ shadowOffset: { width: 0, height: 2 },
+ shadowOpacity: 0.25,
+ shadowRadius: 4,
+ elevation: 5,
+ width: "90%",
+ maxWidth: 400,
+ },
+ modalText: {
+ marginBottom: 20,
+ textAlign: "center",
+ fontSize: 18,
+ fontWeight: "bold",
+ },
+ inputRow: {
+ flexDirection: "row",
+ alignItems: "center",
+ marginBottom: 12,
+ width: "100%",
+ },
+ inputLabel: {
+ flex: 2,
+ fontSize: 14,
+ marginRight: 10,
+ fontWeight: "500",
+ },
+ input: {
+ flex: 1.5,
+ height: 40,
+ borderWidth: 1,
+ borderColor: "#ccc",
+ padding: 10,
+ borderRadius: 5,
+ textAlign: "center",
+ },
+ modalButtonContainer: {
+ flexDirection: "row",
+ justifyContent: "space-around",
+ width: "100%",
+ marginTop: 20,
+ },
+});
+
+export default useDataGenerationModal;
diff --git a/app/(Minitool_one)/tools/RangeTool.jsx b/app/(Minitool_one)/tools/RangeTool.jsx
new file mode 100644
index 0000000..c6289cf
--- /dev/null
+++ b/app/(Minitool_one)/tools/RangeTool.jsx
@@ -0,0 +1,342 @@
+import React, { useCallback } from "react";
+import { Platform } from "react-native";
+import { Gesture, GestureDetector } from "react-native-gesture-handler";
+import Animated, {
+ useSharedValue,
+ useAnimatedProps,
+ useAnimatedStyle,
+ useAnimatedReaction,
+ runOnJS,
+ clamp,
+ withTiming,
+} from "react-native-reanimated";
+import { Rect, Line, G } from "react-native-svg";
+
+const AnimatedG = Animated.createAnimatedComponent(G);
+const AnimatedRect = Animated.createAnimatedComponent(Rect);
+const AnimatedLine = Animated.createAnimatedComponent(Line);
+
+/**
+ * RangeTool Hook
+ * Encapsulates all range tool logic including gestures, animations, and state management
+ * Returns an object with animated props and handlers for integration into the main chart
+ */
+const useRangeTool = ({
+ isActive,
+ onActiveChange,
+ onRangeChange,
+ onCountChange,
+ chartWidth,
+ chartHeight,
+ maxLifespan = 130,
+ initialStartValue = 102,
+ initialEndValue = 126,
+ rangeHandleSize = 15,
+ rangeToolColor = "#0000FF",
+ displayedData = [],
+ X_AXIS_HEIGHT,
+}) => {
+ // --- Range Tool Gesture Logic ---
+ const initialRangeStartX = (initialStartValue / maxLifespan) * chartWidth;
+ const initialRangeEndX = (initialEndValue / maxLifespan) * chartWidth;
+ const rangeStartX = useSharedValue(initialRangeStartX);
+ const rangeEndX = useSharedValue(initialRangeEndX);
+ const rangeContext = useSharedValue({ start: 0, end: 0 });
+
+ const movePanGesture = Gesture.Pan()
+ .onStart(() => {
+ rangeContext.value = { start: rangeStartX.value, end: rangeEndX.value };
+ })
+ .onUpdate((event) => {
+ const rangeWidth = rangeContext.value.end - rangeContext.value.start;
+ const newStart = clamp(
+ rangeContext.value.start + event.translationX,
+ 0,
+ chartWidth - rangeWidth
+ );
+ rangeStartX.value = newStart;
+ rangeEndX.value = newStart + rangeWidth;
+ });
+
+ const leftHandlePanGesture = Gesture.Pan()
+ .onStart(() => {
+ rangeContext.value = { start: rangeStartX.value, end: rangeEndX.value };
+ })
+ .onUpdate((event) => {
+ rangeStartX.value = clamp(
+ rangeContext.value.start + event.translationX,
+ 0,
+ rangeEndX.value - rangeHandleSize
+ );
+ });
+
+ const rightHandlePanGesture = Gesture.Pan()
+ .onStart(() => {
+ rangeContext.value = { start: rangeStartX.value, end: rangeEndX.value };
+ })
+ .onUpdate((event) => {
+ rangeEndX.value = clamp(
+ rangeContext.value.end + event.translationX,
+ rangeStartX.value + rangeHandleSize,
+ chartWidth
+ );
+ });
+
+ // --- Animated Props for range tool elements ---
+ const animatedRangeRectProps = useAnimatedProps(() => ({
+ x: rangeStartX.value,
+ width: rangeEndX.value - rangeStartX.value,
+ }));
+
+ const animatedRangeLeftLineProps = useAnimatedProps(() => ({
+ x1: rangeStartX.value,
+ x2: rangeStartX.value,
+ }));
+
+ const animatedRangeRightLineProps = useAnimatedProps(() => ({
+ x1: rangeEndX.value,
+ x2: rangeEndX.value,
+ }));
+
+ const animatedLeftHandleProps = useAnimatedProps(() => ({
+ x: rangeStartX.value - rangeHandleSize / 2,
+ }));
+
+ const animatedRightHandleProps = useAnimatedProps(() => ({
+ x: rangeEndX.value - rangeHandleSize / 2,
+ }));
+
+ const animatedMoveHandleProps = useAnimatedProps(() => ({
+ x: rangeStartX.value,
+ width: Math.abs(rangeEndX.value - rangeStartX.value),
+ }));
+
+ // --- Animation for label ---
+ const animatedRangeLabelStyle = useAnimatedStyle(() => ({
+ transform: [{ translateX: (rangeStartX.value + rangeEndX.value) / 2 }],
+ opacity: withTiming(isActive ? 1 : 0),
+ }));
+
+ // --- Animation for tool visibility ---
+ const rangeToolContainerAnimatedProps = useAnimatedProps(() => {
+ return { opacity: withTiming(isActive ? 1 : 0) };
+ });
+
+ // --- Function to handle range updates and calculate count ---
+ useAnimatedReaction(
+ () => ({ start: rangeStartX.value, end: rangeEndX.value }),
+ (currentRange, previousRange) => {
+ if (
+ currentRange.start !== previousRange?.start ||
+ currentRange.end !== previousRange?.end
+ ) {
+ const minLifespanValue =
+ (currentRange.start / chartWidth) * maxLifespan;
+ const maxLifespanValue = (currentRange.end / chartWidth) * maxLifespan;
+ const count = displayedData.filter(
+ (item) =>
+ item.visible &&
+ item.lifespan >= minLifespanValue &&
+ item.lifespan <= maxLifespanValue
+ ).length;
+ runOnJS(onCountChange)(count);
+ if (onRangeChange) {
+ runOnJS(onRangeChange)({
+ start: minLifespanValue,
+ end: maxLifespanValue,
+ });
+ }
+ }
+ },
+ [chartWidth, maxLifespan, displayedData]
+ );
+
+ const handleToggle = useCallback(
+ (newValue) => {
+ onActiveChange(newValue);
+ },
+ [onActiveChange]
+ );
+
+ // --- Render component ---
+ const renderRangeTool = () => (
+
+
+
+
+
+ {/* --- Rectangles - gesture handlers --- */}
+ {/* {Platform.OS === "web" && isActive ? ( */}
+ <>
+ {
+ e.preventDefault();
+ e.stopPropagation();
+ const startX = e.nativeEvent.pageX;
+ const initialStart = rangeStartX.value;
+
+ const handleMouseMove = (moveEvent) => {
+ const deltaX = moveEvent.pageX - startX;
+ rangeStartX.value = clamp(
+ initialStart + deltaX,
+ 0,
+ rangeEndX.value - rangeHandleSize
+ );
+ };
+
+ const handleMouseUp = () => {
+ document.removeEventListener("mousemove", handleMouseMove);
+ document.removeEventListener("mouseup", handleMouseUp);
+ };
+
+ document.addEventListener("mousemove", handleMouseMove);
+ document.addEventListener("mouseup", handleMouseUp);
+ }}
+ />
+ {
+ e.preventDefault();
+ e.stopPropagation();
+ const startX = e.nativeEvent.pageX;
+ const initialEnd = rangeEndX.value;
+
+ const handleMouseMove = (moveEvent) => {
+ const deltaX = moveEvent.pageX - startX;
+ rangeEndX.value = clamp(
+ initialEnd + deltaX,
+ rangeStartX.value + rangeHandleSize,
+ chartWidth
+ );
+ };
+
+ const handleMouseUp = () => {
+ document.removeEventListener("mousemove", handleMouseMove);
+ document.removeEventListener("mouseup", handleMouseUp);
+ };
+
+ document.addEventListener("mousemove", handleMouseMove);
+ document.addEventListener("mouseup", handleMouseUp);
+ }}
+ />
+ {
+ e.preventDefault();
+ e.stopPropagation();
+ const startX = e.nativeEvent.pageX;
+ const initialStart = rangeStartX.value;
+ const initialEnd = rangeEndX.value;
+ const rangeWidth = initialEnd - initialStart;
+
+ const handleMouseMove = (moveEvent) => {
+ const deltaX = moveEvent.pageX - startX;
+ const newStart = clamp(
+ initialStart + deltaX,
+ 0,
+ chartWidth - rangeWidth
+ );
+ rangeStartX.value = newStart;
+ rangeEndX.value = newStart + rangeWidth;
+ };
+
+ const handleMouseUp = () => {
+ document.removeEventListener("mousemove", handleMouseMove);
+ document.removeEventListener("mouseup", handleMouseUp);
+ };
+
+ document.addEventListener("mousemove", handleMouseMove);
+ document.addEventListener("mouseup", handleMouseUp);
+ }}
+ />
+ >
+ {/* ) : (
+ <>
+
+
+
+
+
+
+
+
+
+ >
+ )} */}
+
+ );
+
+ return {
+ // Rendered component
+ renderRangeTool,
+
+ // Shared values and gestures (exposed if needed for advanced usage)
+ rangeStartX,
+ rangeEndX,
+ movePanGesture,
+ leftHandlePanGesture,
+ rightHandlePanGesture,
+
+ // Animated props (exposed if needed)
+ animatedRangeRectProps,
+ animatedRangeLeftLineProps,
+ animatedRangeRightLineProps,
+ animatedLeftHandleProps,
+ animatedRightHandleProps,
+ animatedMoveHandleProps,
+ animatedRangeLabelStyle,
+ rangeToolContainerAnimatedProps,
+
+ // Handlers
+ handleToggle,
+ };
+};
+
+export default useRangeTool;
diff --git a/app/(Minitool_one)/tools/ValueTool.jsx b/app/(Minitool_one)/tools/ValueTool.jsx
new file mode 100644
index 0000000..696ba3b
--- /dev/null
+++ b/app/(Minitool_one)/tools/ValueTool.jsx
@@ -0,0 +1,170 @@
+import React, { useCallback } from "react";
+import { Platform } from "react-native";
+import { Gesture, GestureDetector } from "react-native-gesture-handler";
+import Animated, {
+ useSharedValue,
+ useAnimatedProps,
+ useAnimatedStyle,
+ useAnimatedReaction,
+ runOnJS,
+ clamp,
+ withTiming,
+} from "react-native-reanimated";
+import { Rect, Line, G } from "react-native-svg";
+
+const AnimatedG = Animated.createAnimatedComponent(G);
+const AnimatedRect = Animated.createAnimatedComponent(Rect);
+const AnimatedLine = Animated.createAnimatedComponent(Line);
+
+/**
+ * ValueTool Hook
+ * Encapsulates all value tool logic and returns the rendered component
+ * Returns an object with the rendered component and control functions
+ */
+const useValueTool = ({
+ isActive,
+ onActiveChange,
+ onValueChange,
+ chartWidth,
+ chartHeight,
+ maxLifespan = 130,
+ toolValue = 80.0,
+ toolColor = "red",
+ X_AXIS_HEIGHT,
+}) => {
+ // --- Value Tool Gesture Logic ---
+ const initialTranslateX = (toolValue / maxLifespan) * chartWidth;
+ const translateX = useSharedValue(initialTranslateX);
+ const context = useSharedValue({ x: 0 });
+
+ // --- Sync translateX with toolValue when it changes externally ---
+ useAnimatedReaction(
+ () => toolValue,
+ (currentToolValue) => {
+ const newTranslateX = (currentToolValue / maxLifespan) * chartWidth;
+ translateX.value = newTranslateX;
+ },
+ [maxLifespan, chartWidth]
+ );
+
+ const panGesture = Gesture.Pan()
+ .onStart(() => {
+ context.value = { x: translateX.value };
+ })
+ .onUpdate((event) => {
+ translateX.value = clamp(
+ event.translationX + context.value.x,
+ 0,
+ chartWidth
+ );
+ });
+
+ // --- Animated Props for line and handle ---
+ const animatedToolProps = useAnimatedProps(() => ({
+ x: translateX.value - 7.5,
+ }));
+
+ const animatedValueLineProps = useAnimatedProps(() => ({
+ x1: translateX.value,
+ x2: translateX.value,
+ }));
+
+ // --- Animation for label ---
+ const animatedLabelStyle = useAnimatedStyle(() => ({
+ transform: [{ translateX: translateX.value }],
+ opacity: withTiming(isActive ? 1 : 0),
+ }));
+
+ // --- Animation for tool visibility ---
+ const valueToolContainerAnimatedProps = useAnimatedProps(() => {
+ return { opacity: withTiming(isActive ? 1 : 0) };
+ });
+
+ // --- Function to handle value updates ---
+ useAnimatedReaction(
+ () => translateX.value,
+ (currentValue) => {
+ const newValue = (currentValue / chartWidth) * maxLifespan;
+ runOnJS(onValueChange)(newValue);
+ },
+ [chartWidth, maxLifespan]
+ );
+
+ const handleToggle = useCallback(
+ (newValue) => {
+ onActiveChange(newValue);
+ },
+ [onActiveChange]
+ );
+
+ // --- Render component ---
+ const renderValueTool = () => (
+
+
+ {/* {Platform.OS === "web" && isActive ? ( */}
+ {
+ e.preventDefault();
+ e.stopPropagation();
+ const startX = e.nativeEvent.pageX;
+ const initialTranslate = translateX.value;
+
+ const handleMouseMove = (moveEvent) => {
+ const deltaX = moveEvent.pageX - startX;
+ translateX.value = clamp(initialTranslate + deltaX, 0, chartWidth);
+ };
+
+ const handleMouseUp = () => {
+ document.removeEventListener("mousemove", handleMouseMove);
+ document.removeEventListener("mouseup", handleMouseUp);
+ };
+
+ document.addEventListener("mousemove", handleMouseMove);
+ document.addEventListener("mouseup", handleMouseUp);
+ }}
+ />
+ {/* ) : (
+
+
+
+ )} */}
+
+ );
+
+ return {
+ // Rendered component
+ renderValueTool,
+
+ // Shared values and gestures (exposed if needed for advanced usage)
+ translateX,
+ panGesture,
+
+ // Animated props (exposed if needed)
+ animatedToolProps,
+ animatedValueLineProps,
+ animatedLabelStyle,
+ valueToolContainerAnimatedProps,
+
+ // Handlers
+ handleToggle,
+ };
+};
+
+export default useValueTool;
diff --git a/app/(Minitool_three)/_layout.jsx b/app/(Minitool_three)/_layout.jsx
index bbfedc4..66fcee3 100644
--- a/app/(Minitool_three)/_layout.jsx
+++ b/app/(Minitool_three)/_layout.jsx
@@ -1,45 +1,21 @@
-import { View, Text, StyleSheet, Image } from "react-native";
import React from "react";
-import { warn } from "../../constants/icons";
+import { Stack } from "expo-router";
+import { StatusBar } from "react-native";
const Minitool_three_layout = () => {
return (
-
-
-
- Work in Progress!
-
-
- Minitool #3 is currently under development. Please check back later!
-
-
+ <>
+
+
+
+
+ >
);
};
-const styles = StyleSheet.create({
- container: {
- flex: 1,
- justifyContent: "center",
- alignItems: "center",
- backgroundColor: "#f0f0f0",
- padding: 20,
- },
- img: {
- width: 150,
- height: 150,
- marginBottom: 30,
- },
- title: {
- fontSize: 24,
- fontWeight: "bold",
- color: "#333",
- marginBottom: 10,
- },
- subtitle: {
- fontSize: 16,
- color: "#666",
- textAlign: "center",
- },
-});
-
export default Minitool_three_layout;
diff --git a/app/(Minitool_three)/chart_components/ScatterPlot.jsx b/app/(Minitool_three)/chart_components/ScatterPlot.jsx
new file mode 100644
index 0000000..42d369a
--- /dev/null
+++ b/app/(Minitool_three)/chart_components/ScatterPlot.jsx
@@ -0,0 +1,451 @@
+import React, { useState, useCallback, useMemo, useRef } from "react";
+import { View, Dimensions } from "react-native";
+import Svg, {
+ Circle,
+ Line,
+ Text as SvgText,
+ G,
+ Rect,
+} from "react-native-svg";
+import {
+ GestureDetector,
+ Gesture,
+} from "react-native-gesture-handler";
+import { scaleLinear } from "d3-scale";
+import useStatistics from "./useStatistics";
+
+const ScatterPlot = ({
+ data = [],
+ showCross = false,
+ hideData = false,
+ activeGrid = null,
+ twoGroupsCount = null,
+ fourGroupsCount = null,
+ selectedPoint = null,
+ onPointSelect,
+ onScrollEnabled,
+}) => {
+ const { width } = Dimensions.get("window");
+
+ // ── Chart geometry constants ──────────────────────────────────
+ const CHART_WIDTH = width - 160;
+ const CHART_HEIGHT = 500;
+ const PADDING = 40;
+ const Y_AXIS_WIDTH = 50;
+ const X_AXIS_HEIGHT = 40;
+ const PLOT_LEFT = Y_AXIS_WIDTH + PADDING;
+ const PLOT_RIGHT = CHART_WIDTH - PADDING;
+ const PLOT_TOP = PADDING;
+ const PLOT_BOTTOM = CHART_HEIGHT - X_AXIS_HEIGHT - PADDING;
+
+ // ── Scales (memoised) ────────────────────────────────────────
+ const { xScale, yScale, xDomain, yDomain, xTickValues, yTickValues } =
+ useMemo(() => {
+ const xVals = data.map((d) => d.x);
+ const yVals = data.map((d) => d.y);
+ const _minX = Math.min(...xVals);
+ const _maxX = Math.max(...xVals);
+ const _minY = Math.min(...yVals);
+ const _maxY = Math.max(...yVals);
+ const xPad = (_maxX - _minX) * 0.1;
+ const yPad = (_maxY - _minY) * 0.1;
+ const _xDomain = [_minX - xPad, _maxX + xPad];
+ const _yDomain = [_minY - yPad, _maxY + yPad];
+
+ const _xScale = scaleLinear().domain(_xDomain).range([PLOT_LEFT, PLOT_RIGHT]);
+ const _yScale = scaleLinear().domain(_yDomain).range([PLOT_BOTTOM, PLOT_TOP]);
+
+ const xTicks = 6;
+ const yTicks = 6;
+ const _xTickValues = Array.from({ length: xTicks }, (_, i) =>
+ _xDomain[0] + (i * (_xDomain[1] - _xDomain[0])) / (xTicks - 1),
+ );
+ const _yTickValues = Array.from({ length: yTicks }, (_, i) =>
+ _yDomain[0] + (i * (_yDomain[1] - _yDomain[0])) / (yTicks - 1),
+ );
+
+ return {
+ xScale: _xScale,
+ yScale: _yScale,
+ xDomain: _xDomain,
+ yDomain: _yDomain,
+ xTickValues: _xTickValues,
+ yTickValues: _yTickValues,
+ };
+ }, [data, PLOT_LEFT, PLOT_RIGHT, PLOT_TOP, PLOT_BOTTOM]);
+
+ // ── Cross (draggable) state ───────────────────────────────────
+ const midDataX = (xDomain[0] + xDomain[1]) / 2;
+ const midDataY = (yDomain[0] + yDomain[1]) / 2;
+ const [crossCenter, setCrossCenter] = useState({ x: midDataX, y: midDataY });
+ // Keep the cross centred when domain changes (dataset switch)
+ const prevDomainRef = useRef(null);
+ const domainKey = `${xDomain[0]},${xDomain[1]},${yDomain[0]},${yDomain[1]}`;
+ if (prevDomainRef.current !== domainKey) {
+ prevDomainRef.current = domainKey;
+ // reset to centre of new domain
+ crossCenter.x = midDataX;
+ crossCenter.y = midDataY;
+ }
+
+ const crossStartRef = useRef({ x: 0, y: 0 });
+
+ const panGesture = Gesture.Pan()
+ .onBegin(() => {
+ crossStartRef.current = { ...crossCenter };
+ onScrollEnabled?.(false);
+ })
+ .onUpdate((e) => {
+ const dataX =
+ crossStartRef.current.x + e.translationX / ((PLOT_RIGHT - PLOT_LEFT) / (xDomain[1] - xDomain[0]));
+ const dataY =
+ crossStartRef.current.y - e.translationY / ((PLOT_BOTTOM - PLOT_TOP) / (yDomain[1] - yDomain[0]));
+ // Clamp within domain
+ const cx = Math.max(xDomain[0], Math.min(xDomain[1], dataX));
+ const cy = Math.max(yDomain[0], Math.min(yDomain[1], dataY));
+ setCrossCenter({ x: cx, y: cy });
+ })
+ .onEnd(() => {
+ onScrollEnabled?.(true);
+ })
+ .minDistance(0);
+
+ // ── Statistics hook ───────────────────────────────────────────
+ const { gridData, quadrantCounts, twoGroupSlices, fourGroupSlices } =
+ useStatistics({
+ data,
+ activeGrid,
+ twoGroupsCount,
+ fourGroupsCount,
+ crossCenter: showCross ? crossCenter : null,
+ xDomain,
+ yDomain,
+ });
+
+ // ── Tap to select a point ─────────────────────────────────────
+ const handlePointTap = useCallback(
+ (index) => {
+ if (onPointSelect) {
+ onPointSelect(selectedPoint === index ? null : index);
+ }
+ },
+ [onPointSelect, selectedPoint],
+ );
+
+ // ── SVG layer: grid overlay ───────────────────────────────────
+ const renderGridOverlay = () => {
+ if (!gridData) return null;
+ const { counts, xStep, yStep } = gridData;
+ const elements = [];
+ const gridSize = counts.length;
+
+ for (let row = 0; row < gridSize; row++) {
+ for (let col = 0; col < gridSize; col++) {
+ const x1 = xScale(xDomain[0] + col * xStep);
+ const y1 = yScale(yDomain[1] - row * yStep);
+ const x2 = xScale(xDomain[0] + (col + 1) * xStep);
+ const y2 = yScale(yDomain[1] - (row + 1) * yStep);
+ const cellW = x2 - x1;
+ const cellH = y2 - y1;
+
+ elements.push(
+
+
+
+ {counts[row][col]}
+
+ ,
+ );
+ }
+ }
+ return {elements};
+ };
+
+ // ── SVG layer: cross (quadrant) overlay ───────────────────────
+ const renderCrossOverlay = () => {
+ if (!showCross) return null;
+ const cx = xScale(crossCenter.x);
+ const cy = yScale(crossCenter.y);
+
+ const counts = quadrantCounts || [0, 0, 0, 0];
+
+ return (
+
+ {/* Vertical line */}
+
+ {/* Horizontal line */}
+
+ {/* Center handle */}
+
+
+
+ {/* Quadrant counts: TL, TR, BL, BR */}
+ {counts[0]}
+ {counts[1]}
+ {counts[2]}
+ {counts[3]}
+
+ );
+ };
+
+ // ── SVG layer: two-group slicing ──────────────────────────────
+ const renderTwoGroupsOverlay = () => {
+ if (!twoGroupSlices) return null;
+ return (
+
+ {twoGroupSlices.map((s, i) => {
+ const x1 = xScale(s.xLo);
+ const x2 = xScale(s.xHi);
+ const xMid = (x1 + x2) / 2;
+
+ // Slice boundary
+ if (i > 0) {
+ return null; // we draw boundaries below
+ }
+ return null;
+ })}
+ {/* Slice boundaries */}
+ {twoGroupSlices.map((s, i) => (
+
+ {i > 0 && (
+
+ )}
+
+ ))}
+ {/* Stats per slice */}
+ {twoGroupSlices.map((s, i) => {
+ if (s.count === 0) return null;
+ const x1 = xScale(s.xLo);
+ const x2 = xScale(s.xHi);
+ const xMid = (x1 + x2) / 2;
+ const medY = yScale(s.median);
+ const lowY = yScale(s.low);
+ const highY = yScale(s.high);
+
+ return (
+
+ {/* Median line */}
+
+ {/* Low line */}
+
+ {/* High line */}
+
+ {/* Vertical whisker connecting low → high */}
+
+ {/* Labels */}
+
+ Med {Math.round(s.median)}
+
+
+ );
+ })}
+
+ );
+ };
+
+ // ── SVG layer: four-group slicing (box-whisker) ───────────────
+ const renderFourGroupsOverlay = () => {
+ if (!fourGroupSlices) return null;
+ return (
+
+ {/* Slice boundaries */}
+ {fourGroupSlices.map((s, i) => (
+
+ {i > 0 && (
+
+ )}
+
+ ))}
+ {/* Box-whisker per slice */}
+ {fourGroupSlices.map((s, i) => {
+ if (s.count === 0) return null;
+ const x1 = xScale(s.xLo);
+ const x2 = xScale(s.xHi);
+ const xMid = (x1 + x2) / 2;
+ const boxInset = (x2 - x1) * 0.15;
+ const bx1 = x1 + boxInset;
+ const bx2 = x2 - boxInset;
+
+ const lowY = yScale(s.low);
+ const highY = yScale(s.high);
+ const medY = yScale(s.median);
+ const q1Y = s.q1 != null ? yScale(s.q1) : medY;
+ const q3Y = s.q3 != null ? yScale(s.q3) : medY;
+
+ return (
+
+ {/* Whisker: low → Q1 */}
+
+ {/* Whisker: Q3 → high */}
+
+ {/* Low cap */}
+
+ {/* High cap */}
+
+ {/* Box Q1 → Q3 */}
+
+ {/* Median line inside box */}
+
+
+ );
+ })}
+
+ );
+ };
+
+ // ── SVG layer: selected-point projection lines ────────────────
+ const renderSelectedPointOverlay = () => {
+ if (selectedPoint == null || !data[selectedPoint]) return null;
+ const pt = data[selectedPoint];
+ const cx = xScale(pt.x);
+ const cy = yScale(pt.y);
+
+ return (
+
+ {/* Vertical projection to X-axis */}
+
+ {/* Horizontal projection to Y-axis */}
+
+ {/* X-axis value highlight */}
+
+
+ {Math.round(pt.x * 10) / 10}
+
+ {/* Y-axis value highlight */}
+
+
+ {Math.round(pt.y * 10) / 10}
+
+ {/* Highlight ring */}
+
+
+ );
+ };
+
+ // ── Main render ───────────────────────────────────────────────
+ const svgContent = (
+
+ {/* Background grid lines & Y-axis ticks */}
+ {yTickValues.map((tickValue, i) => {
+ const y = yScale(tickValue);
+ return (
+
+
+
+ {Math.round(tickValue)}
+
+
+ );
+ })}
+
+ {/* X-axis ticks */}
+ {xTickValues.map((tickValue, i) => {
+ const x = xScale(tickValue);
+ return (
+
+
+
+ {Math.round(tickValue)}
+
+
+ );
+ })}
+
+ {/* Axes */}
+
+
+
+ {/* Overlays (behind dots so taps register) */}
+ {renderGridOverlay()}
+ {renderTwoGroupsOverlay()}
+ {renderFourGroupsOverlay()}
+ {renderCrossOverlay()}
+
+ {/* Data points */}
+ {data.map((point, index) => {
+ const cx = xScale(point.x);
+ const cy = yScale(point.y);
+ return (
+ handlePointTap(index)}
+ />
+ );
+ })}
+
+ {/* Projection lines for selected point (on top of everything) */}
+ {renderSelectedPointOverlay()}
+
+ {/* Axis labels */}
+
+ Y Variable
+
+
+ X Variable
+
+
+ );
+
+ // Wrap in GestureDetector only when cross is active (for dragging)
+ if (showCross) {
+ return (
+
+
+ {svgContent}
+
+
+ );
+ }
+
+ return (
+
+ {svgContent}
+
+ );
+};
+
+export default ScatterPlot;
diff --git a/app/(Minitool_three)/chart_components/useStatistics.js b/app/(Minitool_three)/chart_components/useStatistics.js
new file mode 100644
index 0000000..f3d121e
--- /dev/null
+++ b/app/(Minitool_three)/chart_components/useStatistics.js
@@ -0,0 +1,158 @@
+import { useMemo } from "react";
+
+// --- Pure math helpers (no React, no hooks) ---
+
+/** Sort an array of numbers ascending (returns new array). */
+const sortAsc = (arr) => [...arr].sort((a, b) => a - b);
+
+/** Median of a *pre-sorted* numeric array. */
+const medianSorted = (sorted) => {
+ const n = sorted.length;
+ if (n === 0) return 0;
+ const mid = Math.floor(n / 2);
+ return n % 2 !== 0 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
+};
+
+/** Q1 of a *pre-sorted* numeric array (lower quartile – median of lower half). */
+const q1Sorted = (sorted) => {
+ const mid = Math.floor(sorted.length / 2);
+ return medianSorted(sorted.slice(0, mid));
+};
+
+/** Q3 of a *pre-sorted* numeric array (upper quartile – median of upper half). */
+const q3Sorted = (sorted) => {
+ const mid = Math.ceil(sorted.length / 2);
+ return medianSorted(sorted.slice(mid));
+};
+
+// ---------------------------------------------------------------------------
+// Grid overlay – count points per cell
+// ---------------------------------------------------------------------------
+export const computeGridCounts = (data, gridSize, xDomain, yDomain) => {
+ const [xMin, xMax] = xDomain;
+ const [yMin, yMax] = yDomain;
+ const xStep = (xMax - xMin) / gridSize;
+ const yStep = (yMax - yMin) / gridSize;
+
+ // Initialize gridSize x gridSize matrix
+ const counts = Array.from({ length: gridSize }, () =>
+ new Array(gridSize).fill(0),
+ );
+
+ for (const { x, y } of data) {
+ let col = Math.floor((x - xMin) / xStep);
+ let row = Math.floor((yMax - y) / yStep); // rows go top→bottom
+ // Clamp to valid range (edge values land on last cell)
+ col = Math.min(col, gridSize - 1);
+ row = Math.min(row, gridSize - 1);
+ if (col >= 0 && row >= 0) counts[row][col]++;
+ }
+ return { counts, xStep, yStep };
+};
+
+// ---------------------------------------------------------------------------
+// Cross (quadrant) – count points in 4 cells
+// ---------------------------------------------------------------------------
+export const computeQuadrantCounts = (data, cx, cy) => {
+ const q = [0, 0, 0, 0]; // TL, TR, BL, BR
+ for (const { x, y } of data) {
+ if (x <= cx && y >= cy) q[0]++;
+ else if (x > cx && y >= cy) q[1]++;
+ else if (x <= cx && y < cy) q[2]++;
+ else q[3]++;
+ }
+ return q;
+};
+
+// ---------------------------------------------------------------------------
+// Two Equal Groups – vertical slicing with Median / Low / High per slice
+// ---------------------------------------------------------------------------
+export const computeTwoGroups = (data, sliceCount, xDomain) => {
+ const [xMin, xMax] = xDomain;
+ const sliceWidth = (xMax - xMin) / sliceCount;
+ const slices = [];
+
+ for (let i = 0; i < sliceCount; i++) {
+ const lo = xMin + i * sliceWidth;
+ const hi = lo + sliceWidth;
+ const points = data.filter(
+ (d) => d.x >= lo && (i === sliceCount - 1 ? d.x <= hi : d.x < hi),
+ );
+ const yVals = sortAsc(points.map((d) => d.y));
+ slices.push({
+ xLo: lo,
+ xHi: hi,
+ low: yVals.length ? yVals[0] : null,
+ high: yVals.length ? yVals[yVals.length - 1] : null,
+ median: yVals.length ? medianSorted(yVals) : null,
+ count: yVals.length,
+ });
+ }
+ return slices;
+};
+
+// ---------------------------------------------------------------------------
+// Four Equal Groups – vertical slicing with Low/Q1/Median/Q3/High per slice
+// ---------------------------------------------------------------------------
+export const computeFourGroups = (data, sliceCount, xDomain) => {
+ const [xMin, xMax] = xDomain;
+ const sliceWidth = (xMax - xMin) / sliceCount;
+ const slices = [];
+
+ for (let i = 0; i < sliceCount; i++) {
+ const lo = xMin + i * sliceWidth;
+ const hi = lo + sliceWidth;
+ const points = data.filter(
+ (d) => d.x >= lo && (i === sliceCount - 1 ? d.x <= hi : d.x < hi),
+ );
+ const yVals = sortAsc(points.map((d) => d.y));
+ slices.push({
+ xLo: lo,
+ xHi: hi,
+ low: yVals.length ? yVals[0] : null,
+ high: yVals.length ? yVals[yVals.length - 1] : null,
+ q1: yVals.length >= 2 ? q1Sorted(yVals) : null,
+ median: yVals.length ? medianSorted(yVals) : null,
+ q3: yVals.length >= 2 ? q3Sorted(yVals) : null,
+ count: yVals.length,
+ });
+ }
+ return slices;
+};
+
+// ---------------------------------------------------------------------------
+// React hook – memoises expensive calculations
+// ---------------------------------------------------------------------------
+const useStatistics = ({
+ data,
+ activeGrid,
+ twoGroupsCount,
+ fourGroupsCount,
+ crossCenter, // { x, y } in DATA coordinates
+ xDomain, // [min, max] of padded x-axis
+ yDomain, // [min, max] of padded y-axis
+}) => {
+ const gridData = useMemo(() => {
+ if (!activeGrid || !data.length) return null;
+ return computeGridCounts(data, activeGrid, xDomain, yDomain);
+ }, [data, activeGrid, xDomain, yDomain]);
+
+ const quadrantCounts = useMemo(() => {
+ if (!crossCenter || !data.length) return null;
+ return computeQuadrantCounts(data, crossCenter.x, crossCenter.y);
+ }, [data, crossCenter]);
+
+ const twoGroupSlices = useMemo(() => {
+ if (!twoGroupsCount || !data.length) return null;
+ return computeTwoGroups(data, twoGroupsCount, xDomain);
+ }, [data, twoGroupsCount, xDomain]);
+
+ const fourGroupSlices = useMemo(() => {
+ if (!fourGroupsCount || !data.length) return null;
+ return computeFourGroups(data, fourGroupsCount, xDomain);
+ }, [data, fourGroupsCount, xDomain]);
+
+ return { gridData, quadrantCounts, twoGroupSlices, fourGroupSlices };
+};
+
+export default useStatistics;
diff --git a/app/(Minitool_three)/controls/ScatterControls.jsx b/app/(Minitool_three)/controls/ScatterControls.jsx
new file mode 100644
index 0000000..cd2786f
--- /dev/null
+++ b/app/(Minitool_three)/controls/ScatterControls.jsx
@@ -0,0 +1,370 @@
+import React, { useState, useRef } from "react";
+import {
+ View,
+ Text,
+ StyleSheet,
+ TouchableOpacity,
+ ScrollView,
+ Switch,
+ Dimensions,
+ Modal,
+ FlatList,
+ TouchableWithoutFeedback,
+} from "react-native";
+import InfoModal from "../modals/InfoModal";
+
+const ScatterControls = ({
+ showCross,
+ onShowCrossChange,
+ hideData,
+ onHideDataChange,
+ activeGrid,
+ onActiveGridChange,
+ twoGroupsCount,
+ onTwoGroupsChange,
+ fourGroupsCount,
+ onFourGroupsChange,
+}) => {
+ const [modalVisible, setModalVisible] = useState(false);
+ const [modalContent, setModalContent] = useState({ title: "", message: "" });
+
+ const groupOptions = [
+ { label: "Off", value: null },
+ ...Array.from({ length: 7 }, (_, i) => ({
+ label: `${i + 4}`,
+ value: i + 4,
+ })),
+ ];
+
+ const gridOptions = [
+ { label: "Off", value: null },
+ ...Array.from({ length: 8 }, (_, i) => ({
+ label: `${i + 3}×${i + 3}`,
+ value: i + 3,
+ })),
+ ];
+
+ const handleInfoPress = (title, message) => {
+ setModalContent({ title, message });
+ setModalVisible(true);
+ };
+
+ const DropdownItem = ({
+ label,
+ infoTitle,
+ infoBody,
+ options,
+ value,
+ onSelect,
+ }) => {
+ const [expanded, setExpanded] = useState(false);
+ const [dropdownTop, setDropdownTop] = useState(0);
+ const [dropdownWidth, setDropdownWidth] = useState(0);
+ const [dropdownLeft, setDropdownLeft] = useState(0);
+
+ const buttonRef = useRef(null);
+
+ const selectedLabel = options?.find((o) => o.value === value)?.label;
+ const headerLabel = selectedLabel ? `${label}: ${selectedLabel}` : `${label}: Off`;
+
+ const toggleDropdown = () => {
+ if (!expanded) {
+ // Measure the button's position on the screen before opening
+ buttonRef.current.measure((x, y, width, height, pageX, pageY) => {
+ setDropdownTop(pageY + height); // Position list exactly below button
+ setDropdownLeft(pageX);
+ setDropdownWidth(width);
+ setExpanded(true);
+ });
+ } else {
+ setExpanded(false);
+ }
+ };
+
+ return (
+
+ {/* 1. Info Icon */}
+ {
+ handleInfoPress(label, infoBody);
+ }}
+ >
+ i
+
+
+
+
+ {headerLabel} ▼
+
+
+
+ {/* 3. The Modal Dropdown List */}
+
+ setExpanded(false)}>
+
+
+ String(item.value)}
+ renderItem={({ item }) => (
+ {
+ onSelect(item.value);
+ setExpanded(false);
+ }}
+ >
+ {item.label}
+
+ )}
+ />
+
+
+
+
+
+ );
+ };
+
+ return (
+
+
+ {/* Left Column: Switches */}
+
+
+ Show Cross
+
+
+
+
+ Hide Data
+
+
+
+
+ {/* Right Column: Dropdowns */}
+
+
+
+
+
+
+ {/* 3. THE MODAL (Placed at the bottom of JSX) */}
+ setModalVisible(false)}
+ />
+
+
+ );
+};
+const styles = StyleSheet.create({
+ scrollContent: {
+ paddingBottom: 150,
+ },
+ mainContainer: {
+ flexDirection: "row", // Side-by-side layout
+ width: "100%",
+ padding: 15,
+ backgroundColor: "#fff",
+ alignItems: "flex-start",
+ zIndex: 1,
+ overflow: "visible",
+ },
+ /* --- Left Column --- */
+ switchColumn: {
+ flex: 1, // Takes 40-50% of space
+ justifyContent: "space-around",
+ borderRightWidth: 1,
+ borderRightColor: "#eee",
+ paddingRight: 10,
+ },
+ switchGroup: {
+ alignItems: "center",
+ marginBottom: 10,
+ },
+ label: {
+ fontSize: 12,
+ color: "#666",
+ marginBottom: 5,
+ fontWeight: "600",
+ },
+ /* --- Right Column --- */
+ dropdownColumn: {
+ flex: 1.5, // Takes more space for the menus
+ paddingLeft: 15,
+ justifyContent: "space-around",
+ overflow: "visible",
+ },
+ dropdownRow: {
+ flexDirection: "row",
+ alignItems: "center",
+ marginBottom: 15,
+ },
+ infoCircle: {
+ width: 24,
+ height: 24,
+ borderRadius: 12,
+ borderWidth: 1,
+ borderColor: "#2563eb",
+ justifyContent: "center",
+ alignItems: "center",
+ marginRight: 8,
+ },
+ infoText: {
+ color: "#2563eb",
+ fontSize: 14,
+ fontWeight: "bold",
+ fontStyle: "italic",
+ },
+ dropdownWrapper: {
+ flex: 1, // Takes up remaining space
+ },
+ dropdownHeader: {
+ height: 45,
+ backgroundColor: "#fff",
+ borderWidth: 1,
+ borderColor: "#ccc",
+ borderRadius: 8,
+ flexDirection: "row",
+ alignItems: "center",
+ justifyContent: "center",
+ paddingHorizontal: 15,
+ },
+ headerText: {
+ fontSize: 14,
+ color: "#333",
+ },
+ /* --- Modal Styles --- */
+ modalOverlay: {
+ flex: 1,
+ width: "100%",
+ height: "100%",
+ },
+ modalOptions: {
+ position: "absolute",
+ backgroundColor: "white",
+ borderRadius: 8,
+ elevation: 5,
+ shadowColor: "#000",
+ shadowOffset: { width: 0, height: 2 },
+ shadowOpacity: 0.2,
+ shadowRadius: 4,
+ borderWidth: 1,
+ borderColor: "#eee",
+ maxHeight: 200,
+ overflow: "hidden",
+ },
+ optionItem: {
+ padding: 12,
+ borderBottomWidth: 1,
+ borderBottomColor: "#f0f0f0",
+ alignItems: "center",
+ },
+ itemText: {
+ color: "#2563eb",
+ fontSize: 14,
+ },
+});
+// const styles = StyleSheet.create({
+// footer: {
+// flexDirection: "row",
+// alignItems: "center", // Centers them vertically relative to each other
+// justifyContent: "space-between",
+// paddingHorizontal: 20,
+// marginTop: 20,
+// width: "100%",
+// zIndex: 10, // Ensure the dropdown inside can float
+// },
+// leftColumn: {
+// flex: 1, // Takes up the available space on the left
+// paddingRight: 10,
+// },
+// rightColumn: {
+// flex: 1,
+// alignItems: "flex-end", // Pushes the dropdown to the right edge
+// },
+// descriptionTitle: {
+// fontSize: 16,
+// fontWeight: "bold",
+// color: "#333",
+// },
+// descriptionSub: {
+// fontSize: 12,
+// color: "#666",
+// marginTop: 2,
+// },
+// dropdownContainer: {
+// width: 150, // Slightly smaller for the side-bar look
+// position: "relative",
+// zIndex: 100,
+// },
+// dropdownHeader: {
+// backgroundColor: "#fff",
+// borderWidth: 1,
+// borderColor: "#ccc",
+// padding: 10,
+// borderRadius: 8,
+// width: "100%",
+// },
+// dropdownList: {
+// position: "absolute",
+// // We use 'bottom: 45' if you want it to open UPWARD
+// // or 'top: 45' to open DOWNWARD
+// top: 45,
+// right: 0,
+// width: "100%",
+// backgroundColor: "#fff",
+// borderWidth: 1,
+// borderColor: "#ccc",
+// borderRadius: 8,
+// elevation: 5,
+// shadowColor: "#000",
+// shadowOffset: { width: 0, height: 2 },
+// shadowOpacity: 0.2,
+// zIndex: 1000,
+// },
+// });
+export default ScatterControls;
diff --git a/app/(Minitool_three)/minitool_3.jsx b/app/(Minitool_three)/minitool_3.jsx
index dbe8758..c86da43 100644
--- a/app/(Minitool_three)/minitool_3.jsx
+++ b/app/(Minitool_three)/minitool_3.jsx
@@ -1,14 +1,275 @@
-import { StyleSheet, Text, View } from "react-native";
-import React from "react";
+import React, { useState } from "react";
+import {
+ StyleSheet,
+ View,
+ ScrollView,
+ SafeAreaView,
+ Text,
+ Dimensions,
+ StatusBar,
+ TouchableOpacity,
+} from "react-native";
+import { GestureHandlerRootView } from "react-native-gesture-handler";
+import ScatterPlot from "./chart_components/ScatterPlot";
+import ScatterControls from "./controls/ScatterControls";
+import DataInfo from "./modals/InfoModal";
+import bivariateData from "../../data/bivariate_set.json";
+
+const Minitool_3 = () => {
+ const [showCross, setShowCross] = useState(false);
+ const [hideData, setHideData] = useState(false);
+ const [activeGrid, setActiveGrid] = useState(null);
+ const [twoGroupsCount, setTwoGroupsCount] = useState(null);
+ const [fourGroupsCount, setFourGroupsCount] = useState(null);
+ const [selectedPoint, setSelectedPoint] = useState(null);
+ const [scrollEnabled, setScrollEnabled] = useState(true);
+
+ const handleActiveGridChange = (val) => {
+ setActiveGrid(val);
+ setTwoGroupsCount(null);
+ setFourGroupsCount(null);
+ };
+
+ const handleTwoGroupsChange = (val) => {
+ setTwoGroupsCount(val);
+ setActiveGrid(null);
+ setFourGroupsCount(null);
+ };
+
+ const handleFourGroupsChange = (val) => {
+ setFourGroupsCount(val);
+ setActiveGrid(null);
+ setTwoGroupsCount(null);
+ };
+
+ const [currentKey, setCurrentKey] = useState("dataset1");
+ const [isOpen, setIsOpen] = useState(false);
+
+ const currentData = bivariateData[currentKey];
+
+ const { width } = Dimensions.get("window");
-const minitool_3 = () => {
return (
-
- minitool_3
-
+
+
+
+
+
+ Scatter Plot Analysis
+ Bivariate Data Visualization
+
+
+
+ {/* The Dropdown Overaly */}
+
+ setIsOpen(!isOpen)}
+ >
+ {currentKey} ▼
+
+
+ {isOpen && (
+
+ {Object.keys(bivariateData).map((key) => (
+ {
+ setCurrentKey(key);
+ setIsOpen(false);
+ }}
+ >
+ {key}
+
+ ))}
+
+ )}
+
+
+ {/* Main Chart Section */}
+
+
+
+
+ {/* Controls and Info Row */}
+
+
+
+
+ {/*
+
+ */}
+
+
+ {/* Additional Info */}
+ {/*
+ Current Display Mode:
+ {displayMode}
+
+ More features coming soon! Features like cross analysis, grid
+ overlay, and grouping options will be available.
+
+ */}
+
+
+
);
};
-export default minitool_3;
+const styles = StyleSheet.create({
+ safeArea: {
+ flex: 1,
+ backgroundColor: "#f8f9fa",
+ },
+ headerContainer: {
+ backgroundColor: "#2a7f9f",
+ paddingVertical: 16,
+ paddingHorizontal: 20,
+ borderBottomWidth: 2,
+ borderBottomColor: "#1e5f7f",
+ },
+ title: {
+ fontSize: 24,
+ fontWeight: "bold",
+ color: "#fff",
+ marginBottom: 4,
+ },
+ subtitle: {
+ fontSize: 13,
+ color: "#e0e0e0",
+ },
+ mainContainer: {
+ flex: 1,
+ backgroundColor: "#e5e7eb",
+ },
+ contentContainer: {
+ paddingVertical: 16,
+ },
+ chartSection: {
+ backgroundColor: "#cc1111",
+ marginHorizontal: 10,
+ marginVertical: 10,
+ borderRadius: 8,
+ overflow: "hidden",
+ shadowColor: "#000",
+ shadowOffset: { width: 0, height: 2 },
+ shadowOpacity: 0.1,
+ shadowRadius: 4,
+ elevation: 3,
+ },
+ controlsSection: {
+ flexDirection: "row",
+ justifyContent: "space-between",
+ alignItems: "flex-start",
+ marginHorizontal: 10,
+ marginVertical: 8,
+ },
+ infoBox: {
+ backgroundColor: "#fff",
+ marginHorizontal: 10,
+ marginVertical: 10,
+ padding: 16,
+ borderRadius: 8,
+ borderLeftWidth: 4,
+ borderLeftColor: "#2563eb",
+ shadowColor: "#000",
+ shadowOffset: { width: 0, height: 2 },
+ shadowOpacity: 0.1,
+ shadowRadius: 4,
+ elevation: 2,
+ },
+ infoTitle: {
+ fontSize: 14,
+ fontWeight: "bold",
+ color: "#333",
+ marginBottom: 8,
+ },
+ infoText: {
+ fontSize: 13,
+ color: "#2563eb",
+ fontWeight: "600",
+ marginBottom: 8,
+ textTransform: "capitalize",
+ },
+ infoDescription: {
+ fontSize: 12,
+ color: "#666",
+ lineHeight: 18,
+ },
+
+ dropdownContainer: {
+ alignSelf: "center",
+ width: 300,
+ zIndex: 100, // Crucial for iOS to stay above the SVG
+ position: "relative", // Keeps the list anchored to the header
+ },
+ dropdownHeader: {
+ backgroundColor: "#f8f9fa",
+ borderWidth: 1,
+ borderColor: "#ccc",
+ padding: 8,
+ borderRadius: 4,
+ alignItems: "center",
+ height: 40, // Fixed height helps with alignment
+ justifyContent: "center",
+ },
+ dropdownList: {
+ position: "absolute", // Makes it float over the graph
+ top: 42, // Position it just below the header (header height + margin)
+ left: 0,
+ right: 0,
+ backgroundColor: "#fff",
+ borderWidth: 1,
+ borderColor: "#ccc",
+ borderRadius: 4,
+ maxHeight: 200, // Optional: add height limit if you have many items
+ zIndex: 1000, // Ensure it's the top-most layer
+ elevation: 5,
+ shadowColor: "#000",
+ shadowOffset: { width: 0, height: 2 },
+ shadowOpacity: 0.2,
+ overflow: "hidden", // Keeps items within the rounded borders
+ },
+ headerText: {
+ fontWeight: "600",
+ color: "#333",
+ },
+ item: {
+ padding: 10,
+ borderBottomWidth: StyleSheet.hairlineWidth,
+ borderBottomColor: "#eee",
+ },
+ itemText: {
+ textAlign: "center",
+ color: "#2563eb",
+ },
+});
-const styles = StyleSheet.create({});
+export default Minitool_3;
diff --git a/app/(Minitool_three)/modals/InfoModal.jsx b/app/(Minitool_three)/modals/InfoModal.jsx
new file mode 100644
index 0000000..1293054
--- /dev/null
+++ b/app/(Minitool_three)/modals/InfoModal.jsx
@@ -0,0 +1,89 @@
+import React from "react";
+import {
+ View,
+ Text,
+ Modal,
+ TouchableOpacity,
+ StyleSheet,
+ Pressable,
+} from "react-native";
+
+const InfoModal = ({ visible, title, message, onClose }) => {
+ return (
+
+ {/* Semi-transparent Backdrop - clicking it also closes the modal */}
+
+ {/* Modal Card - Pressable prevents the click from bubbling up to backdrop */}
+ e.stopPropagation()}
+ >
+ {title}
+
+ {message}
+
+
+ Got it
+
+
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ backdrop: {
+ flex: 1,
+ backgroundColor: "rgba(0, 0, 0, 0.5)",
+ justifyContent: "center",
+ alignItems: "center",
+ padding: 20,
+ },
+ modalCard: {
+ width: "100%",
+ maxWidth: 400, // Good for web layout
+ backgroundColor: "white",
+ borderRadius: 12,
+ padding: 20,
+ elevation: 10,
+ shadowColor: "#000",
+ shadowOffset: { width: 0, height: 4 },
+ shadowOpacity: 0.25,
+ shadowRadius: 10,
+ },
+ title: {
+ fontSize: 18,
+ fontWeight: "bold",
+ color: "#333",
+ marginBottom: 10,
+ },
+ divider: {
+ height: 1,
+ backgroundColor: "#eee",
+ marginBottom: 15,
+ },
+ message: {
+ fontSize: 14,
+ color: "#666",
+ lineHeight: 20,
+ marginBottom: 20,
+ },
+ closeButton: {
+ backgroundColor: "#2563eb",
+ paddingVertical: 12,
+ borderRadius: 8,
+ alignItems: "center",
+ },
+ closeButtonText: {
+ color: "white",
+ fontWeight: "600",
+ fontSize: 14,
+ },
+});
+
+export default InfoModal;
diff --git a/data/bivariate_set.json b/data/bivariate_set.json
new file mode 100644
index 0000000..003b5e6
--- /dev/null
+++ b/data/bivariate_set.json
@@ -0,0 +1,151 @@
+{
+ "dataset1": [
+ { "x": 150, "y": 520 },
+ { "x": 160, "y": 540 },
+ { "x": 165, "y": 560 },
+ { "x": 170, "y": 570 },
+ { "x": 175, "y": 580 },
+ { "x": 180, "y": 600 },
+ { "x": 185, "y": 620 },
+ { "x": 190, "y": 640 },
+ { "x": 195, "y": 660 },
+ { "x": 200, "y": 680 },
+ { "x": 155, "y": 530 },
+ { "x": 162, "y": 550 },
+ { "x": 168, "y": 565 },
+ { "x": 172, "y": 575 },
+ { "x": 178, "y": 590 },
+ { "x": 182, "y": 610 },
+ { "x": 188, "y": 630 },
+ { "x": 192, "y": 650 },
+ { "x": 198, "y": 670 },
+ { "x": 205, "y": 690 },
+ { "x": 152, "y": 525 },
+ { "x": 158, "y": 545 },
+ { "x": 164, "y": 555 },
+ { "x": 171, "y": 572 },
+ { "x": 177, "y": 585 },
+ { "x": 181, "y": 605 },
+ { "x": 187, "y": 625 },
+ { "x": 193, "y": 655 },
+ { "x": 199, "y": 675 },
+ { "x": 203, "y": 685 },
+ { "x": 148, "y": 515 },
+ { "x": 163, "y": 555 },
+ { "x": 169, "y": 568 },
+ { "x": 174, "y": 582 },
+ { "x": 179, "y": 595 },
+ { "x": 183, "y": 615 },
+ { "x": 189, "y": 635 },
+ { "x": 194, "y": 660 },
+ { "x": 201, "y": 680 },
+ { "x": 207, "y": 695 }
+ ],
+ "dataset2": [
+ { "x": 20, "y": 80 },
+ { "x": 50, "y": 10 },
+ { "x": 90, "y": 60 }
+ ],
+ "dataset3": [
+ { "x": 95, "y": 212 },
+ { "x": 98, "y": 220 },
+ { "x": 101, "y": 216 },
+ { "x": 104, "y": 227 },
+ { "x": 107, "y": 236 },
+ { "x": 110, "y": 230 },
+ { "x": 113, "y": 243 },
+ { "x": 116, "y": 239 },
+ { "x": 119, "y": 247 },
+ { "x": 122, "y": 256 },
+ { "x": 125, "y": 252 },
+ { "x": 128, "y": 264 },
+ { "x": 131, "y": 260 },
+ { "x": 134, "y": 273 },
+ { "x": 137, "y": 268 },
+ { "x": 140, "y": 281 },
+ { "x": 143, "y": 277 },
+ { "x": 146, "y": 286 },
+ { "x": 149, "y": 294 },
+ { "x": 152, "y": 289 },
+ { "x": 155, "y": 301 },
+ { "x": 158, "y": 297 },
+ { "x": 161, "y": 310 },
+ { "x": 164, "y": 304 },
+ { "x": 167, "y": 316 },
+ { "x": 170, "y": 312 },
+ { "x": 173, "y": 325 },
+ { "x": 176, "y": 320 },
+ { "x": 179, "y": 332 },
+ { "x": 182, "y": 328 },
+ { "x": 185, "y": 339 },
+ { "x": 188, "y": 336 },
+ { "x": 191, "y": 347 },
+ { "x": 194, "y": 344 },
+ { "x": 197, "y": 356 },
+ { "x": 200, "y": 351 },
+ { "x": 203, "y": 364 },
+ { "x": 206, "y": 359 },
+ { "x": 209, "y": 371 },
+ { "x": 212, "y": 368 },
+ { "x": 215, "y": 378 },
+ { "x": 218, "y": 373 },
+ { "x": 221, "y": 386 },
+ { "x": 224, "y": 381 },
+ { "x": 227, "y": 393 },
+ { "x": 230, "y": 389 },
+ { "x": 233, "y": 401 },
+ { "x": 236, "y": 397 },
+ { "x": 239, "y": 409 },
+ { "x": 242, "y": 404 },
+ { "x": 245, "y": 417 },
+ { "x": 248, "y": 412 },
+ { "x": 251, "y": 424 },
+ { "x": 254, "y": 420 },
+ { "x": 257, "y": 431 },
+ { "x": 260, "y": 428 },
+ { "x": 263, "y": 439 },
+ { "x": 266, "y": 435 },
+ { "x": 269, "y": 448 },
+ { "x": 272, "y": 444 },
+ { "x": 275, "y": 455 },
+ { "x": 278, "y": 452 },
+ { "x": 281, "y": 462 },
+ { "x": 284, "y": 459 },
+ { "x": 287, "y": 471 },
+ { "x": 290, "y": 466 },
+ { "x": 293, "y": 479 },
+ { "x": 296, "y": 474 },
+ { "x": 299, "y": 486 },
+ { "x": 302, "y": 482 },
+ { "x": 305, "y": 494 },
+ { "x": 308, "y": 490 },
+ { "x": 311, "y": 501 },
+ { "x": 314, "y": 497 },
+ { "x": 317, "y": 510 },
+ { "x": 320, "y": 505 },
+ { "x": 323, "y": 517 },
+ { "x": 326, "y": 513 },
+ { "x": 329, "y": 525 },
+ { "x": 332, "y": 520 },
+ { "x": 335, "y": 533 },
+ { "x": 338, "y": 528 },
+ { "x": 341, "y": 540 },
+ { "x": 344, "y": 536 },
+ { "x": 347, "y": 547 },
+ { "x": 350, "y": 543 },
+ { "x": 353, "y": 555 },
+ { "x": 356, "y": 550 },
+ { "x": 359, "y": 563 },
+ { "x": 362, "y": 558 },
+ { "x": 365, "y": 570 },
+ { "x": 368, "y": 566 },
+ { "x": 371, "y": 579 },
+ { "x": 374, "y": 574 },
+ { "x": 377, "y": 586 },
+ { "x": 380, "y": 582 },
+ { "x": 383, "y": 593 },
+ { "x": 386, "y": 589 },
+ { "x": 389, "y": 601 },
+ { "x": 392, "y": 596 }
+ ]
+}
diff --git a/package-lock.json b/package-lock.json
index 9447f5f..cb6d270 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -25,6 +25,7 @@
"react-dom": "19.0.0",
"react-native": "0.79.3",
"react-native-charts-wrapper": "^0.6.0",
+ "react-native-element-dropdown": "^2.12.4",
"react-native-gesture-handler": "^2.26.0",
"react-native-gifted-charts": "^1.4.55",
"react-native-linear-gradient": "^2.8.3",
@@ -3202,6 +3203,7 @@
"resolved": "https://registry.npmjs.org/@react-navigation/native/-/native-7.1.11.tgz",
"integrity": "sha512-f/UETxy2Nahr8jko9mSSRBvIaDubGc3M2yx5pWxMPxZgLkB4TqPB0O1OFdbcAuRDwLgzXXK+Joh7nTdGug9v2A==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@react-navigation/core": "^7.10.1",
"escape-string-regexp": "^4.0.0",
@@ -3435,6 +3437,7 @@
"integrity": "sha512-ixLZ7zG7j1fM0DijL9hDArwhwcCb4vqmePgwtV0GfnkHRSCUEv4LvzarcTdhoqgyMznUx/EhoTUv31CKZzkQlw==",
"devOptional": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@@ -3533,6 +3536,7 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT",
+ "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -4252,6 +4256,7 @@
}
],
"license": "MIT",
+ "peer": true,
"dependencies": {
"caniuse-lite": "^1.0.30001718",
"electron-to-chromium": "^1.5.160",
@@ -5311,6 +5316,7 @@
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
+ "peer": true,
"engines": {
"node": ">=12"
}
@@ -6053,6 +6059,7 @@
"integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -6308,6 +6315,7 @@
"resolved": "https://registry.npmjs.org/expo/-/expo-53.0.11.tgz",
"integrity": "sha512-+QtvU+6VPd7/o4vmtwuRE/Li2rAiJtD25I6BOnoQSxphaWWaD0PdRQnIV3VQ0HESuJYRuKJ3DkAHNJ3jI6xwzA==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@babel/runtime": "^7.20.0",
"@expo/cli": "0.24.14",
@@ -6371,6 +6379,7 @@
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-17.1.6.tgz",
"integrity": "sha512-q5mLvJiLtPcaZ7t2diSOlQ2AyxIO8YMVEJsEfI/ExkGj15JrflNQ7CALEW6IF/uNae/76qI/XcjEuuAyjdaCNw==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@expo/config": "~11.0.9",
"@expo/env": "~1.0.5"
@@ -6395,6 +6404,7 @@
"resolved": "https://registry.npmjs.org/expo-font/-/expo-font-13.3.1.tgz",
"integrity": "sha512-d+xrHYvSM9WB42wj8vP9OOFWyxed5R1evphfDb6zYBmC1dA9Hf89FpT7TNFtj2Bk3clTnpmVqQTCYbbA2P3CLg==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"fontfaceobserver": "^2.1.0"
},
@@ -6418,6 +6428,7 @@
"resolved": "https://registry.npmjs.org/expo-linear-gradient/-/expo-linear-gradient-14.1.5.tgz",
"integrity": "sha512-BSN3MkSGLZoHMduEnAgfhoj3xqcDWaoICgIr4cIYEx1GcHfKMhzA/O4mpZJ/WC27BP1rnAqoKfbclk1eA70ndQ==",
"license": "MIT",
+ "peer": true,
"peerDependencies": {
"expo": "*",
"react": "*",
@@ -6429,6 +6440,7 @@
"resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-7.1.5.tgz",
"integrity": "sha512-8g20zOpROW78bF+bLI4a3ZWj4ntLgM0rCewKycPL0jk9WGvBrBtFtwwADJgOiV1EurNp3lcquerXGlWS+SOQyA==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"expo-constants": "~17.1.6",
"invariant": "^2.2.4"
@@ -8271,6 +8283,7 @@
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"license": "MIT",
+ "peer": true,
"bin": {
"jiti": "bin/jiti.js"
}
@@ -11782,6 +11795,7 @@
"version": "4.0.2",
"inBundle": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=12"
},
@@ -12666,6 +12680,7 @@
}
],
"license": "MIT",
+ "peer": true,
"dependencies": {
"nanoid": "^3.3.7",
"picocolors": "^1.1.1",
@@ -13053,6 +13068,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.0.0.tgz",
"integrity": "sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -13093,6 +13109,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.0.0.tgz",
"integrity": "sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"scheduler": "^0.25.0"
},
@@ -13129,6 +13146,7 @@
"resolved": "https://registry.npmjs.org/react-native/-/react-native-0.79.3.tgz",
"integrity": "sha512-EzH1+9gzdyEo9zdP6u7Sh3Jtf5EOMwzy+TK65JysdlgAzfEVfq4mNeXcAZ6SmD+CW6M7ARJbvXLyTD0l2S5rpg==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@jest/create-cache-key-function": "^29.7.0",
"@react-native/assets-registry": "0.79.3",
@@ -13261,6 +13279,22 @@
"react-native": "*"
}
},
+ "node_modules/react-native-element-dropdown": {
+ "version": "2.12.4",
+ "resolved": "https://registry.npmjs.org/react-native-element-dropdown/-/react-native-element-dropdown-2.12.4.tgz",
+ "integrity": "sha512-abZc5SVji9FIt7fjojRYrbuvp03CoeZJrgvezQoDoSOrpiTqkX69ix5m+j06W2AVncA0VWvbT+vCMam8SoVadw==",
+ "license": "MIT",
+ "dependencies": {
+ "lodash": "^4.17.21"
+ },
+ "engines": {
+ "node": ">= 16.0.0"
+ },
+ "peerDependencies": {
+ "react": "*",
+ "react-native": "*"
+ }
+ },
"node_modules/react-native-gesture-handler": {
"version": "2.26.0",
"resolved": "https://registry.npmjs.org/react-native-gesture-handler/-/react-native-gesture-handler-2.26.0.tgz",
@@ -13315,6 +13349,7 @@
"resolved": "https://registry.npmjs.org/react-native-linear-gradient/-/react-native-linear-gradient-2.8.3.tgz",
"integrity": "sha512-KflAXZcEg54PXkLyflaSZQ3PJp4uC4whM7nT/Uot9m0e/qxFV3p6uor1983D1YOBJbJN7rrWdqIjq0T42jOJyA==",
"license": "MIT",
+ "peer": true,
"peerDependencies": {
"react": "*",
"react-native": "*"
@@ -13338,6 +13373,7 @@
"resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-3.17.5.tgz",
"integrity": "sha512-SxBK7wQfJ4UoWoJqQnmIC7ZjuNgVb9rcY5Xc67upXAFKftWg0rnkknTw6vgwnjRcvYThrjzUVti66XoZdDJGtw==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@babel/plugin-transform-arrow-functions": "^7.0.0-0",
"@babel/plugin-transform-class-properties": "^7.0.0-0",
@@ -13363,6 +13399,7 @@
"resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.4.0.tgz",
"integrity": "sha512-JaEThVyJcLhA+vU0NU8bZ0a1ih6GiF4faZ+ArZLqpYbL6j7R3caRqj+mE3lEtKCuHgwjLg3bCxLL1GPUJZVqUA==",
"license": "MIT",
+ "peer": true,
"peerDependencies": {
"react": "*",
"react-native": "*"
@@ -13379,6 +13416,7 @@
"resolved": "https://registry.npmjs.org/react-native-screens/-/react-native-screens-4.11.1.tgz",
"integrity": "sha512-F0zOzRVa3ptZfLpD0J8ROdo+y1fEPw+VBFq1MTY/iyDu08al7qFUO5hLMd+EYMda5VXGaTFCa8q7bOppUszhJw==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"react-freeze": "^1.0.0",
"react-native-is-edge-to-edge": "^1.1.7",
@@ -13394,6 +13432,7 @@
"resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-15.12.0.tgz",
"integrity": "sha512-iE25PxIJ6V0C6krReLquVw6R0QTsRTmEQc4K2Co3P6zsimU/jltcDBKYDy1h/5j9S/fqmMeXnpM+9LEWKJKI6A==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"css-select": "^5.1.0",
"css-tree": "^1.1.3",
@@ -13982,6 +14021,7 @@
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
@@ -14904,6 +14944,7 @@
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz",
"integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
"arg": "^5.0.2",
diff --git a/package.json b/package.json
index b12fe7e..fd3c44a 100644
--- a/package.json
+++ b/package.json
@@ -30,6 +30,7 @@
"react-dom": "19.0.0",
"react-native": "0.79.3",
"react-native-charts-wrapper": "^0.6.0",
+ "react-native-element-dropdown": "^2.12.4",
"react-native-gesture-handler": "^2.26.0",
"react-native-gifted-charts": "^1.4.55",
"react-native-linear-gradient": "^2.8.3",