Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Vector controller #44

Merged
merged 5 commits into from
Dec 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 1 addition & 4 deletions src/components/GravitySimulator/GravitySimulator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,7 @@
document.removeEventListener("fullscreenchange", handleFullscreenChange);
}, []);

const handleDrag = useCallback(

Check warning on line 180 in src/components/GravitySimulator/GravitySimulator.tsx

View workflow job for this annotation

GitHub Actions / build

React Hook useCallback received a function whose dependencies are unknown. Pass an inline function instead
throttle(() => {
if (blockInteractions) return;

Expand All @@ -188,7 +188,7 @@
[blockInteractions]
);

const handleReportNewPosition = useCallback(

Check warning on line 191 in src/components/GravitySimulator/GravitySimulator.tsx

View workflow job for this annotation

GitHub Actions / build

React Hook useCallback received a function whose dependencies are unknown. Pass an inline function instead
throttle((point: Point2D, index: number) => {
if (!offset) return;
setGravityPoints((points) =>
Expand Down Expand Up @@ -227,10 +227,7 @@
physicsConfig.PARTICLES_EXERT_GRAVITY
);
const force = new Point(calculatedForce.x, calculatedForce.y).add(
new Point(
physicsConfig.CONSTANT_FORCE_X,
physicsConfig.CONSTANT_FORCE_Y
)
new Point(physicsConfig.CONSTANT_FORCE)
);

const acceleration = calculateAcceleration(force, particle.mass);
Expand Down Expand Up @@ -577,7 +574,7 @@

onApiReady(api);
},
[

Check warning on line 577 in src/components/GravitySimulator/GravitySimulator.tsx

View workflow job for this annotation

GitHub Actions / build

React Hook useEffect has missing dependencies: 'createParticle', 'gravityPoints', 'gravityRef', 'handlePointDelete', 'handleSelectScenario', 'isFullscreen', 'isPaused', 'onApiReady', 'particles', 'physicsConfig', 'toggleFullscreen', and 'updateSettings'. Either include them or remove the dependency array. If 'onApiReady' changes too often, find the parent component that defines it and wrap that definition in useCallback
// Add all deps
]
);
Expand Down
51 changes: 48 additions & 3 deletions src/components/SimulatorSettings/SimulatorSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,11 @@ import {
DEFAULT_PHYSICS_CONFIG,
SETTINGS_METADATA,
SliderSettingMetadata,
VectorSettingMetadata,
} from "../../constants/physics";
import { useSettings } from "../../contexts/SettingsContext";
import { VectorController } from "../VectorController/VectorController";
import { Point2D } from "../../utils/types/physics";

interface SimulatorSettingsProps {
onSettingsChange: (settings: typeof DEFAULT_PHYSICS_CONFIG) => void;
Expand All @@ -25,7 +28,10 @@ export const SimulatorSettings: React.FC<SimulatorSettingsProps> = ({
isDevelopment,
} = useSettings();

const handleSettingChange = (key: keyof typeof settings, value: number) => {
const handleSettingChange = (
key: keyof typeof settings,
value: number | Point2D
) => {
const newSettings = { [key]: value };
updateSettings(newSettings);
onSettingsChange({ ...settings, ...newSettings });
Expand All @@ -44,7 +50,7 @@ export const SimulatorSettings: React.FC<SimulatorSettingsProps> = ({
};

const shouldShowSetting = (key: keyof typeof DEFAULT_PHYSICS_CONFIG) => {
const isDevSetting = SETTINGS_METADATA[key].isDev;
const isDevSetting = SETTINGS_METADATA[key]?.isDev;
return !isDevSetting || (isDevelopment && showDevSettings);
};

Expand Down Expand Up @@ -139,7 +145,46 @@ export const SimulatorSettings: React.FC<SimulatorSettingsProps> = ({
<div key={key} style={{ marginBottom: "16px" }}>
{SETTINGS_METADATA[
key as keyof typeof DEFAULT_PHYSICS_CONFIG
].type === "boolean" ? (
].type === "vector" ? (
<div key={key} style={{ marginBottom: "16px" }}>
<label style={{ fontSize: "0.9rem" }}>
{key.replace(/_/g, " ")}
</label>
<VectorController
value={value as Point2D}
onChange={(newValue) =>
handleSettingChange(
key as keyof typeof DEFAULT_PHYSICS_CONFIG,
newValue
)
}
max={
(
SETTINGS_METADATA[
key as keyof typeof DEFAULT_PHYSICS_CONFIG
] as VectorSettingMetadata
).max
}
width={100}
height={100}
/>
<div
style={{
display: "flex",
justifyContent: "space-between",
marginTop: "8px",
fontSize: "0.85rem",
fontFamily: "monospace",
color: "rgba(255, 255, 255, 0.7)",
}}
>
<span>x: {(value as Point2D).x.toFixed(3)}</span>
<span>y: {(value as Point2D).y.toFixed(3)}</span>
</div>
</div>
) : SETTINGS_METADATA[
key as keyof typeof DEFAULT_PHYSICS_CONFIG
].type === "boolean" ? (
<label
style={{
display: "flex",
Expand Down
215 changes: 215 additions & 0 deletions src/components/VectorController/VectorController.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
import React, { useState, useRef, useEffect, useCallback } from "react";
import { Point2D } from "../../utils/types/physics";
import Paper from "paper";
import { createArrow } from "../../utils/physics/vectorUtils";

interface VectorControllerProps {
value: Point2D;
onChange: (value: Point2D) => void;
max: Point2D;
width?: number;
height?: number;
}

export const VectorController: React.FC<VectorControllerProps> = ({
value,
onChange,
max,
width = 100,
height = 100,
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const scopeRef = useRef<paper.PaperScope>();
const [isDragging, setIsDragging] = useState(false);

const pixelToNormalized = useCallback(
(pixel: Point2D): Point2D => ({
x: pixel.x / (width / 2) - 1,
y: pixel.y / (height / 2) - 1,
}),
[width, height]
);

const normalizedToValue = useCallback(
(normalized: Point2D): Point2D => ({
x: normalized.x * max.x,
y: normalized.y * max.y,
}),
[max]
);

const valueToNormalized = useCallback(
(val: Point2D): Point2D => ({
x: val.x / max.x,
y: val.y / max.y,
}),
[max]
);

const drawArrow = useCallback(() => {
if (!scopeRef.current) return;

const scope = scopeRef.current;
scope.activate();
scope.project.clear();

const center = new scope.Point(width / 2, height / 2);
const normalized = valueToNormalized(value);
const direction = new scope.Point(normalized.x, normalized.y);

// Create background circle
new scope.Path.Circle({
center,
radius: Math.min(width, height) / 2 - 1,
fillColor: "rgba(255, 255, 255, 0.1)",
});

// Draw x and y axes
new scope.Path.Line({
from: [0, height / 2],
to: [width, height / 2],
strokeColor: "rgba(255, 255, 255, 0.2)",
strokeWidth: 1,
});

new scope.Path.Line({
from: [width / 2, 0],
to: [width / 2, height],
strokeColor: "rgba(255, 255, 255, 0.2)",
strokeWidth: 1,
});

// Create center point
new scope.Path.Circle({
center,
radius: 3,
fillColor: "rgba(255, 255, 255, 0.5)",
});

// Only create arrow if there's a direction
if (direction.length > 0) {
createArrow(
center,
direction,
"#FF4081",
Math.min(width, height) / 2 - 10
);
}

scope.view.update();
}, [width, height, value, valueToNormalized]);

useEffect(() => {
if (!canvasRef.current) return;

scopeRef.current = new Paper.PaperScope();
scopeRef.current.setup(canvasRef.current);

requestAnimationFrame(drawArrow);

return () => {
if (scopeRef.current) {
scopeRef.current.project.clear();
}
};
}, [drawArrow]);

const handlePointerDown = (e: React.PointerEvent) => {
if (!containerRef.current) return;

const rect = containerRef.current.getBoundingClientRect();
const pixel: Point2D = {
x: e.clientX - rect.left,
y: e.clientY - rect.top,
};

// Check if click is in the center area (10px radius from center)
const centerX = width / 2;
const centerY = height / 2;
const distanceFromCenter = Math.sqrt(
Math.pow(pixel.x - centerX, 2) + Math.pow(pixel.y - centerY, 2)
);

if (distanceFromCenter <= 10) {
// Reset to 0,0 if clicking center
onChange({ x: 0, y: 0 });
return;
}

const normalized = pixelToNormalized(pixel);
const clampedNormalized = {
x: Math.max(-1, Math.min(1, normalized.x)),
y: Math.max(-1, Math.min(1, normalized.y)),
};

onChange(normalizedToValue(clampedNormalized));
setIsDragging(true);
};

const handlePointerMove = useCallback(
(e: PointerEvent) => {
if (!isDragging || !containerRef.current) return;

const rect = containerRef.current.getBoundingClientRect();
const pixel: Point2D = {
x: e.clientX - rect.left,
y: e.clientY - rect.top,
};

const normalized = pixelToNormalized(pixel);
const clampedNormalized = {
x: Math.max(-1, Math.min(1, normalized.x)),
y: Math.max(-1, Math.min(1, normalized.y)),
};

onChange(normalizedToValue(clampedNormalized));
},
[isDragging, normalizedToValue, pixelToNormalized, onChange]
);

const handlePointerUp = useCallback(() => {
setIsDragging(false);
}, []);

useEffect(() => {
if (isDragging) {
window.addEventListener("pointermove", handlePointerMove);
window.addEventListener("pointerup", handlePointerUp);

return () => {
window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("pointerup", handlePointerUp);
};
}
}, [isDragging, handlePointerMove, handlePointerUp]);

useEffect(() => {
drawArrow();
}, [drawArrow]);

return (
<div
ref={containerRef}
onPointerDown={handlePointerDown}
style={{
width,
height,
position: "relative",
borderRadius: "50%",
cursor: "pointer",
touchAction: "none",
}}
>
<canvas
ref={canvasRef}
width={width}
height={height}
style={{
width: "100%",
height: "100%",
}}
/>
</div>
);
};
38 changes: 20 additions & 18 deletions src/constants/physics.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { StarTemplate } from "../types/star";
import { GravityPoint } from "../utils/types/physics";
import { GravityPoint, Point2D } from "../utils/types/physics";
import { Point } from "paper";

export interface SliderSettingMetadata {
Expand All @@ -15,7 +15,19 @@ export interface BooleanSettingMetadata {
isDev: boolean;
}

type SettingMetadata = SliderSettingMetadata | BooleanSettingMetadata;
export interface VectorSettingMetadata {
type: "vector";
isDev: boolean;
max: Point2D;
min: Point2D;
label?: string;
}

type SettingMetadata =
| SliderSettingMetadata
| BooleanSettingMetadata
| VectorSettingMetadata;

export interface PhysicsSettings {
NEW_PARTICLE_MASS: number;
NEW_PARTICLE_ELASTICITY: number;
Expand All @@ -24,8 +36,7 @@ export interface PhysicsSettings {
POINTER_MASS: number;
SHOW_VELOCITY_ARROWS: boolean;
SHOW_FORCE_ARROWS: boolean;
CONSTANT_FORCE_X: number;
CONSTANT_FORCE_Y: number;
CONSTANT_FORCE: Point2D;
SOLID_BOUNDARIES: boolean;
PARTICLES_EXERT_GRAVITY: boolean;
PARTICLE_TRAIL_LENGTH: number;
Expand All @@ -38,8 +49,7 @@ export const DEFAULT_PHYSICS_CONFIG: PhysicsSettings = {
POINTER_MASS: 500000,
SHOW_VELOCITY_ARROWS: true,
SHOW_FORCE_ARROWS: true,
CONSTANT_FORCE_X: 0,
CONSTANT_FORCE_Y: 0,
CONSTANT_FORCE: { x: 0, y: 0 },
SOLID_BOUNDARIES: true,
PARTICLES_EXERT_GRAVITY: false,
PARTICLE_TRAIL_LENGTH: 30,
Expand Down Expand Up @@ -92,19 +102,11 @@ export const SETTINGS_METADATA: Record<
type: "boolean",
isDev: false,
},
CONSTANT_FORCE_X: {
type: "slider",
isDev: false,
min: -10,
max: 10,
step: 0.1,
},
CONSTANT_FORCE_Y: {
type: "slider",
CONSTANT_FORCE: {
type: "vector",
isDev: false,
min: -10,
max: 10,
step: 0.1,
max: { x: 2, y: 2 },
min: { x: -2, y: -2 },
},
SOLID_BOUNDARIES: {
type: "boolean",
Expand Down
Loading
Loading