Skip to content

Commit f63a83d

Browse files
committed
feat(gui): make fixed points size flexible
1 parent 3d33aa7 commit f63a83d

6 files changed

Lines changed: 208 additions & 144 deletions

File tree

frontend/src/Anton.tsx

Lines changed: 127 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,7 @@ const createCoordinateSystem = (
279279
const xSpan = Math.abs(range.xMax - range.xMin);
280280
const ySpan = Math.abs(range.yMax - range.yMin);
281281

282+
const viewSpan = Math.min(xSpan, ySpan);
282283
const xTickStep = calculateNiceStep(xSpan);
283284
const yTickStep = calculateNiceStep(ySpan);
284285

@@ -352,30 +353,38 @@ const createCoordinateSystem = (
352353
const material = new THREE.SpriteMaterial({ map: texture, transparent: true, depthTest: false });
353354
const sprite = new THREE.Sprite(material);
354355
sprite.position.copy(position);
355-
sprite.scale.set(fontSize * 2, fontSize, 1);
356+
sprite.scale.set(fontSize * 2.2, fontSize, 1);
356357
sprite.userData.isGrid = true;
357358
return sprite;
358359
};
359360

360-
const numXTicks = Math.ceil(limit / xTickStep);
361-
for (let i = -numXTicks; i <= numXTicks; i++) {
361+
const labelFontSize = Math.min(0.12, Math.max(0.004, viewSpan * 0.028));
362+
const xLabelOffsetY = -Math.min(0.14, Math.max(0.005, ySpan * 0.035));
363+
const yLabelOffsetX = -Math.min(0.14, Math.max(0.005, xSpan * 0.035));
364+
365+
// Only place tick labels in the active visible view
366+
const minXTick = Math.floor((range.xMin - xTickStep) / xTickStep);
367+
const maxXTick = Math.ceil((range.xMax + xTickStep) / xTickStep);
368+
for (let i = minXTick; i <= maxXTick; i++) {
362369
const x = Number((i * xTickStep).toFixed(6));
363-
if (Math.abs(x) > 0.001 && Math.abs(x) <= limit) {
364-
gridGroup.add(createTextSprite(formatTickNumber(x), new THREE.Vector3(x, -0.14, 0), 0.12));
370+
if (Math.abs(x) > 0.0001 && Math.abs(x) <= limit) {
371+
gridGroup.add(createTextSprite(formatTickNumber(x), new THREE.Vector3(x, xLabelOffsetY, 0), labelFontSize));
365372
}
366373
}
367374

368-
const numYTicks = Math.ceil(limit / yTickStep);
369-
for (let i = -numYTicks; i <= numYTicks; i++) {
375+
const minYTick = Math.floor((range.yMin - yTickStep) / yTickStep);
376+
const maxYTick = Math.ceil((range.yMax + yTickStep) / yTickStep);
377+
for (let i = minYTick; i <= maxYTick; i++) {
370378
const y = Number((i * yTickStep).toFixed(6));
371-
if (Math.abs(y) > 0.001 && Math.abs(y) <= limit) {
372-
gridGroup.add(createTextSprite(formatTickNumber(y), new THREE.Vector3(-0.14, y, 0), 0.12));
379+
if (Math.abs(y) > 0.0001 && Math.abs(y) <= limit) {
380+
gridGroup.add(createTextSprite(formatTickNumber(y), new THREE.Vector3(yLabelOffsetX, y, 0), labelFontSize));
373381
}
374382
}
375383

376-
gridGroup.add(createTextSprite('x', new THREE.Vector3(range.xMax - 0.18, 0.12, 0), 0.18));
377-
gridGroup.add(createTextSprite('y', new THREE.Vector3(0.12, range.yMax - 0.14, 0), 0.18));
378-
gridGroup.add(createTextSprite('0', new THREE.Vector3(-0.12, -0.12, 0), 0.1));
384+
const axisLabelSize = labelFontSize * 1.3;
385+
gridGroup.add(createTextSprite('x', new THREE.Vector3(range.xMax - xSpan * 0.04, ySpan * 0.03, 0), axisLabelSize));
386+
gridGroup.add(createTextSprite('y', new THREE.Vector3(xSpan * 0.03, range.yMax - ySpan * 0.04, 0), axisLabelSize));
387+
gridGroup.add(createTextSprite('0', new THREE.Vector3(yLabelOffsetX, xLabelOffsetY, 0), labelFontSize * 0.9));
379388

380389
scene.add(gridGroup);
381390
return gridGroup;
@@ -2775,6 +2784,12 @@ const SetValuedViz = () => {
27752784
scene.add(line);
27762785
}
27772786

2787+
const ySpan = Math.abs(viewportRange.yMax - viewportRange.yMin);
2788+
const { height: vpHeight } = readViewportSize();
2789+
const frustumH = Math.max(1e-6, ySpan + 0.24);
2790+
const pxPerUnit = vpHeight / frustumH;
2791+
const radius = Math.max(1e-6, 6.0 / pxPerUnit);
2792+
27782793
manifoldState.fixedPoints.forEach(fp => {
27792794
const stabLower = (fp.stability || '').toLowerCase();
27802795
const isAttractor = stabLower === 'attractor' || stabLower === 'stable';
@@ -2785,11 +2800,37 @@ const SetValuedViz = () => {
27852800
isDualRepeller ? ORBIT_COLORS.dualRepeller :
27862801
isRepeller ? ORBIT_COLORS.repeller :
27872802
isSaddle ? ORBIT_COLORS.saddlePoint : ORBIT_COLORS.periodicBlue;
2788-
const radius = isAttractor ? 0.03 : (isRepeller || isDualRepeller) ? 0.028 : 0.025;
2789-
const geom = new THREE.SphereGeometry(radius, 12, 12);
2790-
const mat = new THREE.MeshBasicMaterial({ color: new THREE.Color(color) });
2803+
2804+
const fpRadius = radius * (isAttractor ? 1.15 : (isRepeller || isDualRepeller) ? 1.1 : 1.0);
2805+
2806+
// Dark high-contrast outer ring so fixed point is clearly separated from manifold lines
2807+
const ringGeom = new THREE.RingGeometry(fpRadius * 0.9, fpRadius * 1.35, 32);
2808+
const ringMat = new THREE.MeshBasicMaterial({
2809+
color: new THREE.Color('#000000'),
2810+
transparent: true,
2811+
opacity: 0.95,
2812+
depthTest: false,
2813+
depthWrite: false,
2814+
side: THREE.DoubleSide
2815+
});
2816+
const ring = new THREE.Mesh(ringGeom, ringMat);
2817+
ring.position.set(fp.x, fp.y, 0.49);
2818+
ring.renderOrder = 99;
2819+
ring.userData = { type: 'fixedPoint' };
2820+
scene.add(ring);
2821+
2822+
const geom = new THREE.CircleGeometry(fpRadius, 32);
2823+
const mat = new THREE.MeshBasicMaterial({
2824+
color: new THREE.Color(color),
2825+
transparent: true,
2826+
opacity: 1.0,
2827+
depthTest: false,
2828+
depthWrite: false,
2829+
side: THREE.DoubleSide
2830+
});
27912831
const sphere = new THREE.Mesh(geom, mat);
2792-
sphere.position.set(fp.x, fp.y, 0.2);
2832+
sphere.position.set(fp.x, fp.y, 0.5);
2833+
sphere.renderOrder = 100;
27932834
sphere.userData = {
27942835
type: 'fixedPoint',
27952836
period: 1,
@@ -2814,35 +2855,60 @@ const SetValuedViz = () => {
28142855
} else {
28152856
manifoldState.trajectoryPoints.forEach((point, idx) => {
28162857
const normalizedIdx = idx / manifoldState.trajectoryPoints.length;
2817-
const size = 0.022 * (0.4 + 0.6 * normalizedIdx);
2818-
const geom = new THREE.SphereGeometry(size, 8, 8);
2819-
const mat = new THREE.MeshBasicMaterial({ color: new THREE.Color(ORBIT_COLORS.trajectory), opacity: 0.4 + 0.6 * normalizedIdx, transparent: true });
2858+
const size = Math.max(1e-6, (4.5 * (0.4 + 0.6 * normalizedIdx)) / pxPerUnit);
2859+
const geom = new THREE.CircleGeometry(size, 16);
2860+
const mat = new THREE.MeshBasicMaterial({
2861+
color: new THREE.Color(ORBIT_COLORS.trajectory),
2862+
opacity: 0.4 + 0.6 * normalizedIdx,
2863+
transparent: true,
2864+
depthTest: false,
2865+
depthWrite: false,
2866+
side: THREE.DoubleSide
2867+
});
28202868
const sphere = new THREE.Mesh(geom, mat);
2821-
sphere.position.set(point.x, point.y, 0.25);
2869+
sphere.position.set(point.x, point.y, 0.45);
2870+
sphere.renderOrder = 40;
28222871
sphere.userData.type = 'trajectory';
28232872
scene.add(sphere);
28242873
});
28252874
}
28262875
}
28272876

28282877
if (manifoldState.hasStarted && manifoldState.currentPoint) {
2829-
const glowGeom = new THREE.RingGeometry(0.05, 0.05, 20);
2878+
const glowRadius = Math.max(1e-6, 7.5 / pxPerUnit);
2879+
const glowGeom = new THREE.RingGeometry(glowRadius * 0.8, glowRadius * 1.2, 32);
28302880
const currentColor = dynamicSystem === 'duffing_ode' ? ORBIT_COLORS.manifold : ORBIT_COLORS.trajectory;
2831-
const glowMat = new THREE.MeshBasicMaterial({ color: new THREE.Color(currentColor), opacity: 0.6, transparent: true, side: THREE.DoubleSide });
2881+
const glowMat = new THREE.MeshBasicMaterial({
2882+
color: new THREE.Color(currentColor),
2883+
opacity: 0.6,
2884+
transparent: true,
2885+
side: THREE.DoubleSide,
2886+
depthTest: false,
2887+
depthWrite: false
2888+
});
28322889
const glowRing = new THREE.Mesh(glowGeom, glowMat);
2833-
glowRing.position.set(manifoldState.currentPoint.x, manifoldState.currentPoint.y, 0.3);
2890+
glowRing.position.set(manifoldState.currentPoint.x, manifoldState.currentPoint.y, 0.51);
2891+
glowRing.renderOrder = 101;
28342892
glowRing.userData.type = 'trajectory';
28352893
scene.add(glowRing);
28362894

2837-
const geom = new THREE.SphereGeometry(0.02, 16, 16);
2838-
const mat = new THREE.MeshBasicMaterial({ color: new THREE.Color('#ffffff') });
2895+
const geom = new THREE.CircleGeometry(Math.max(1e-6, 5.0 / pxPerUnit), 32);
2896+
const mat = new THREE.MeshBasicMaterial({
2897+
color: new THREE.Color('#ffffff'),
2898+
transparent: true,
2899+
opacity: 1.0,
2900+
depthTest: false,
2901+
depthWrite: false,
2902+
side: THREE.DoubleSide
2903+
});
28392904
const sphere = new THREE.Mesh(geom, mat);
2840-
sphere.position.set(manifoldState.currentPoint.x, manifoldState.currentPoint.y, 0.3);
2905+
sphere.position.set(manifoldState.currentPoint.x, manifoldState.currentPoint.y, 0.52);
2906+
sphere.renderOrder = 102;
28412907
sphere.userData.type = 'trajectory';
28422908
scene.add(sphere);
28432909
}
28442910

2845-
}, [manifoldState, geometricOffsetState, bdeState, dynamicSystem, type, viewRange, readViewportSize, geometricOffsetBoundaryPoints, boundaryLayers, hasBoundarySamples, hasVerifiedBoundaryCycles, params.epsilon]);
2911+
}, [manifoldState, geometricOffsetState, bdeState, dynamicSystem, type, viewRange, viewportRange, readViewportSize, geometricOffsetBoundaryPoints, boundaryLayers, hasBoundarySamples, hasVerifiedBoundaryCycles, params.epsilon]);
28462912

28472913
useEffect(() => {
28482914
if (!sceneRef.current) return;
@@ -2890,17 +2956,45 @@ const SetValuedViz = () => {
28902956
return ORBIT_COLORS.periodicBlue;
28912957
};
28922958

2959+
const ySpan = Math.abs(viewportRange.yMax - viewportRange.yMin);
2960+
const { height: vpHeight } = readViewportSize();
2961+
const frustumH = Math.max(1e-6, ySpan + 0.24);
2962+
const pxPerUnit = vpHeight / frustumH;
2963+
const orbitSphereRadius = Math.max(1e-6, 5.5 / pxPerUnit);
2964+
28932965
if (manifoldState.showOrbits) {
28942966
periodicState.orbits.filter(isVisible).forEach((orbit, orbitIndex) => {
28952967
const orbitId = `orbit-${orbit.period}-${orbitIndex}`;
28962968
const pointColor = colorForOrbit(orbit);
28972969
orbitExtendedStates(orbit).forEach((extendedPoint, pointIndex) => {
2898-
const geometry = new THREE.SphereGeometry(0.02, 10, 10);
2970+
// Dark high-contrast outer ring so orbit point is clearly visible on top of manifold lines
2971+
const ringGeom = new THREE.RingGeometry(orbitSphereRadius * 0.9, orbitSphereRadius * 1.35, 32);
2972+
const ringMat = new THREE.MeshBasicMaterial({
2973+
color: new THREE.Color('#000000'),
2974+
transparent: true,
2975+
opacity: 0.95,
2976+
depthTest: false,
2977+
depthWrite: false,
2978+
side: THREE.DoubleSide
2979+
});
2980+
const ring = new THREE.Mesh(ringGeom, ringMat);
2981+
ring.position.set(extendedPoint.x, extendedPoint.y, 0.49);
2982+
ring.renderOrder = 99;
2983+
ring.userData = { type: 'orbit' };
2984+
scene.add(ring);
2985+
2986+
const geometry = new THREE.CircleGeometry(orbitSphereRadius, 32);
28992987
const material = new THREE.MeshBasicMaterial({
2900-
color: new THREE.Color(pointColor)
2988+
color: new THREE.Color(pointColor),
2989+
transparent: true,
2990+
opacity: 1.0,
2991+
depthTest: false,
2992+
depthWrite: false,
2993+
side: THREE.DoubleSide
29012994
});
29022995
const sphere = new THREE.Mesh(geometry, material);
2903-
sphere.position.set(extendedPoint.x, extendedPoint.y, 0.05);
2996+
sphere.position.set(extendedPoint.x, extendedPoint.y, 0.5);
2997+
sphere.renderOrder = 100;
29042998
sphere.userData = {
29052999
type: 'orbit',
29063000
resultRevision,
@@ -2961,7 +3055,9 @@ const SetValuedViz = () => {
29613055
manifoldState.showOrbits,
29623056
periodicState.isReady,
29633057
periodicState.orbits,
2964-
periodicState.resultRevision
3058+
periodicState.resultRevision,
3059+
readViewportSize,
3060+
viewportRange
29653061
]);
29663062

29673063
const stepForwardManifold = useCallback(() => {

frontend/src/components/layout/Viewport.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -191,16 +191,16 @@ export const Viewport = ({ type, canvasRef, tooltip, manifoldState, geometricOff
191191
<span className="tt-v" style={{ color: tooltipData.stability === 'stable' ? 'var(--green)' : tooltipData.stability === 'saddle' ? 'var(--amber)' : tooltipData.stability === 'dualrepeller' ? '#a855f7' : 'var(--red)' }}>
192192
{tooltipData.stability === 'dualrepeller' ? 'Dual repeller' : tooltipData.stability?.charAt(0).toUpperCase() + tooltipData.stability?.slice(1)}
193193
</span>
194-
{tooltipData.eigenvalues && (
194+
{tooltipData.eigenvalues && tooltipData.eigenvalues.length > 0 && (
195195
<>
196-
<span className="tt-k">Eigenvalues</span>
196+
<span className="tt-k">{tooltipData.eigenvalues.length === 3 ? 'Multipliers (3D)' : 'Eigenvalues'}</span>
197197
<span className="tt-v">{tooltipData.eigenvalues.map(v => v.toFixed(3)).join(', ')}</span>
198198
</>
199199
)}
200200
{tooltipData.jacobian && (
201201
<>
202-
<span className="tt-k">det(J)</span><span className="tt-v">{tooltipData.jacobian.det.toFixed(3)}</span>
203-
<span className="tt-k">tr(J)</span><span className="tt-v">{tooltipData.jacobian.trace.toFixed(3)}</span>
202+
<span className="tt-k">det(J_2d)</span><span className="tt-v">{tooltipData.jacobian.det.toFixed(3)}</span>
203+
<span className="tt-k">tr(J_2d)</span><span className="tt-v">{tooltipData.jacobian.trace.toFixed(3)}</span>
204204
</>
205205
)}
206206
</div>

frontend/src/test/ManifoldsPanel.test.tsx

Lines changed: 2 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ describe('ManifoldsPanel', () => {
8484
expect(screen.getByLabelText('Show points')).toBeDisabled();
8585
});
8686

87-
it('shows intersection detection panel when stable manifold enabled', () => {
87+
it('toggles stable manifold correctly', () => {
8888
const setManifoldState = vi.fn();
8989
render(
9090
<ManifoldsPanel
@@ -94,60 +94,7 @@ describe('ManifoldsPanel', () => {
9494
/>
9595
);
9696

97-
expect(screen.getByText(/Detection threshold/)).toBeInTheDocument();
98-
});
99-
100-
it('hides intersection panel when stable manifold disabled', () => {
101-
const setManifoldState = vi.fn();
102-
render(
103-
<ManifoldsPanel
104-
manifoldState={{ ...defaultManifoldState, showStableManifold: false }}
105-
setManifoldState={setManifoldState}
106-
ORBIT_COLORS={ORBIT_COLORS}
107-
/>
108-
);
109-
110-
expect(screen.queryByText(/Detection threshold/)).toBeNull();
111-
});
112-
113-
it('shows heteroclinic warning when intersections found', () => {
114-
const setManifoldState = vi.fn();
115-
render(
116-
<ManifoldsPanel
117-
manifoldState={{
118-
...defaultManifoldState,
119-
showStableManifold: true,
120-
intersections: [
121-
{ has_intersection: true, min_distance: 0.02 },
122-
{ has_intersection: false, min_distance: 0.5 },
123-
],
124-
}}
125-
setManifoldState={setManifoldState}
126-
ORBIT_COLORS={ORBIT_COLORS}
127-
/>
128-
);
129-
130-
expect(screen.getByText(/Heteroclinic connection/)).toBeInTheDocument();
131-
expect(screen.getByText(/1 connection found/)).toBeInTheDocument();
132-
});
133-
134-
it('shows no connections message when intersections checked but none found', () => {
135-
const setManifoldState = vi.fn();
136-
render(
137-
<ManifoldsPanel
138-
manifoldState={{
139-
...defaultManifoldState,
140-
showStableManifold: true,
141-
intersections: [
142-
{ has_intersection: false, min_distance: 0.3 },
143-
],
144-
}}
145-
setManifoldState={setManifoldState}
146-
ORBIT_COLORS={ORBIT_COLORS}
147-
/>
148-
);
149-
150-
expect(screen.getByText(/No heteroclinic connections/)).toBeInTheDocument();
97+
expect(screen.getByLabelText('Stable manifold')).toBeChecked();
15198
});
15299

153100
it('renders saddle orbit source selector and only shows saddle periods', () => {

frontend/src/utils/extendedOrbitState.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ describe('extended periodic-orbit state', () => {
3232
expect(enrichSolutionPointsWithOrbitNormals([
3333
{ x: 3, y: 4, stability: 'saddle' }
3434
], [orbit])).toEqual([
35-
{ x: 3, y: 4, nx: -0.8, ny: 0.6, period: 2, stability: 'saddle' }
35+
{ x: 3, y: 4, nx: -0.8, ny: 0.6, period: 2, stability: 'saddle', eigenvalues: [2, 0.5] }
3636
]);
3737
});
3838
});

frontend/src/utils/extendedOrbitState.ts

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -50,17 +50,26 @@ export const enrichSolutionPointsWithOrbitNormals = (
5050
): SolutionPoint[] => {
5151
const candidates = (orbits || []).flatMap(orbit => orbitExtendedStates(orbit).map(state => ({
5252
...state,
53-
period: orbit.period
53+
period: orbit.period,
54+
stability: orbit.stability,
55+
eigenvalues: Array.isArray(orbit.eigenvalues) ? orbit.eigenvalues : []
5456
})));
5557
return (solutionPoints || []).map(point => {
56-
if (Number.isFinite(point?.nx) && Number.isFinite(point?.ny)) return point;
5758
const match = candidates.find(candidate => (
5859
Math.hypot(candidate.x - point.x, candidate.y - point.y) <= positionTolerance
59-
&& Number.isFinite(candidate.nx)
60-
&& Number.isFinite(candidate.ny)
6160
));
62-
return match
63-
? { ...point, nx: match.nx, ny: match.ny, period: point.period || match.period }
64-
: point;
61+
if (match) {
62+
return {
63+
...point,
64+
nx: Number.isFinite(match.nx) ? match.nx : point.nx,
65+
ny: Number.isFinite(match.ny) ? match.ny : point.ny,
66+
period: point.period || match.period,
67+
stability: match.stability || point.stability,
68+
eigenvalues: (match.eigenvalues && match.eigenvalues.length > 0)
69+
? match.eigenvalues
70+
: point.eigenvalues
71+
};
72+
}
73+
return point;
6574
});
6675
};

0 commit comments

Comments
 (0)