Skip to content
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
23 changes: 12 additions & 11 deletions frontend/src/lib/api/services/ajax.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,18 +43,19 @@ export const ajaxService = {
height?: number,
width?: number,
shape?: string,
polygon?: string
polygon?: string,
unit?: string
): Promise<PlotlyFigure> => {
const response = await client.get<PlotlyFigure>('/ajax_preview_footprint', {
params: {
ra,
dec,
radius,
height,
width,
shape,
polygon
}
// POST a JSON body: large multi-polygon footprints can exceed URL limits.
const response = await client.post<PlotlyFigure>('/ajax_preview_footprint', {
ra,
dec,
radius,
height,
width,
shape,
unit,
polygon
});
return response.data;
},
Expand Down
46 changes: 35 additions & 11 deletions frontend/src/lib/services/instrumentService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,18 @@ export async function submitInstrument(
};
}

// Handle authentication/authorization errors. The backend returns a
// token-centric message ("provide a valid JWT token or API token"),
// which is confusing on the website where users log in via a session.
if (axiosError.response?.status === 401 || axiosError.response?.status === 403) {
const message = 'You must be logged in to submit an instrument. Please log in and try again.';
return {
success: false,
message,
errors: [message]
};
}

// Handle 400 Bad Request errors
if (axiosError.response?.status === 400) {
const detail =
Expand Down Expand Up @@ -180,23 +192,35 @@ export async function previewFootprint(
instrumentData: Partial<InstrumentData>
): Promise<{ data: unknown; layout: unknown }> {
try {
const params = new URLSearchParams();

// Set default coordinates for preview (centered at 0,0)
params.append('ra', '0');
params.append('dec', '0');
params.append('shape', instrumentData.footprint_type || 'Circular');
// POST the params as a JSON body: multi-polygon footprints (e.g. LSST's
// ~170 CCDs) can exceed URL length limits, causing intermittent 414s.
const body: Record<string, string | number> = {
// Preview centered at 0,0; unit drives the same degree scaling as
// instrument creation so the preview axes are faithful to reality.
ra: 0,
dec: 0,
shape: instrumentData.footprint_type || 'Circular',
unit: instrumentData.unit || 'deg'
};

if (instrumentData.footprint_type === 'Rectangular') {
if (instrumentData.height) params.append('height', instrumentData.height.toString());
if (instrumentData.width) params.append('width', instrumentData.width.toString());
if (instrumentData.height) body.height = instrumentData.height;
if (instrumentData.width) body.width = instrumentData.width;
} else if (instrumentData.footprint_type === 'Circular') {
if (instrumentData.radius) params.append('radius', instrumentData.radius.toString());
if (instrumentData.radius) body.radius = instrumentData.radius;
} else if (instrumentData.footprint_type === 'Polygon') {
if (instrumentData.polygon) params.append('polygon', instrumentData.polygon);
if (instrumentData.polygon) body.polygon = instrumentData.polygon;
}

const response = await api.client.post('/ajax_preview_footprint', body);

// The endpoint returns { error } when the footprint params can't be
// parsed (e.g. malformed polygon text). Surface it instead of returning
// an object with no data/layout, which would silently fail to render.
if (response.data?.error) {
throw new Error(response.data.error);
}

const response = await api.client.get(`/ajax_preview_footprint?${params.toString()}`);
return response.data;
} catch (error) {
console.error('Failed to preview footprint:', error);
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/routes/submit/instruments/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -594,8 +594,10 @@
}

.preview-plot {
/* Square container so the 1:1-constrained plot fills it without wide
empty margins. */
width: 100%;
height: 400px;
aspect-ratio: 1 / 1;
}

.preview-error {
Expand Down
142 changes: 73 additions & 69 deletions server/routes/ui/preview_footprint.py
Original file line number Diff line number Diff line change
@@ -1,102 +1,106 @@
"""Preview footprint endpoint."""

from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from fastapi import APIRouter
from pydantic import BaseModel

from server.db.database import get_db
from server.utils.footprint_processing import (
get_scale_factor,
create_circular_footprint,
create_rectangular_footprint,
parse_multi_polygon,
)

router = APIRouter(tags=["UI"])


@router.get("/ajax_preview_footprint")
async def preview_footprint(
ra: float,
dec: float,
radius: float = None,
height: float = None,
width: float = None,
shape: str = "Circular",
polygon: str = None,
db: Session = Depends(get_db),
):
"""Generate a preview of an instrument footprint."""
import math
import json
import plotly
class FootprintPreviewRequest(BaseModel):
"""Parameters for a footprint preview.

Sent as a POST body rather than a query string: multi-polygon footprints
(e.g. the ~170-CCD LSST camera) can produce very large payloads that would
exceed URL/request-line length limits and fail with a 414.
"""

shape: str = "Circular"
unit: str = "deg"
ra: float = 0.0
dec: float = 0.0
radius: float | None = None
height: float | None = None
width: float | None = None
polygon: str | None = None


@router.post("/ajax_preview_footprint")
async def preview_footprint(request: FootprintPreviewRequest):
"""Generate a preview of an instrument footprint.

Uses the same footprint parsing/scaling helpers as instrument creation, so
the preview is faithful to what will be stored — including unit scaling.
This lets a user spot a mistake (wrong unit or dimensions) before
submitting, since the axes are in real degrees.
"""
import plotly.graph_objects as go

# This is a UI helper endpoint to visualize a footprint before saving
# It generates the appropriate visualization for the given parameters
# Scale raw inputs to degrees using the selected unit, matching the
# conversion applied on instrument creation (get_scale_factor).
scale = get_scale_factor(request.unit)
vertices = []

if shape == "Circular" and radius:
# For circle, generate points around the circumference
circle_points = []
for i in range(36): # 36 points for a smooth circle
# Proper spherical coordinate calculation for circles
# Convert angle to offset in RA/Dec using spherical trigonometry
x = radius * math.cos(math.radians(90 - i * 10))
y = radius * math.sin(math.radians(90 - i * 10))
# Adjust for spherical coordinates
if abs(x) < 1e-10:
x = 0.0
if abs(y) < 1e-10:
y = 0.0
point_ra = ra + x
point_dec = dec + y
circle_points.append([point_ra, point_dec])

# Close the polygon
circle_points.append(circle_points[0])
vertices.append(circle_points)

elif shape == "Rectangular" and height and width:
# For rectangle, generate corners
# Convert height/width in degrees to ra/dec coordinates
# Proper calculation accounting for coordinate system
half_width = width / 2
half_height = height / 2

# No cos(dec) correction needed for simple rectangular footprints
ra_offset = half_width

rect_points = [
[ra - ra_offset, dec - half_height], # bottom left
[ra - ra_offset, dec + half_height], # top left
[ra + ra_offset, dec + half_height], # top right
[ra + ra_offset, dec - half_height], # bottom right
[ra - ra_offset, dec - half_height], # close the polygon
]
vertices.append(rect_points)

elif shape == "Polygon" and polygon:
# For custom polygon, parse the points
try:
poly_points = json.loads(polygon)
poly_points.append(poly_points[0]) # Close the polygon
vertices.append(poly_points)
except json.JSONDecodeError:
if request.shape == "Circular" and request.radius:
vertices.append(create_circular_footprint(request.radius, scale))

elif request.shape == "Rectangular" and request.height and request.width:
vertices.append(
create_rectangular_footprint(request.height, request.width, scale)
)

elif request.shape == "Polygon" and request.polygon:
# Parse the UI text format:
# [(x, y) ... ] # [(x, y) ... ] (multi-polygon, "#"-delimited)
# or a single newline-separated list of "(x, y)" coordinate pairs.
polygons, errors = parse_multi_polygon(request.polygon, scale)
if errors:
return {"error": "; ".join(errors)}
if not polygons:
return {"error": "Invalid polygon format"}
vertices.extend(polygons)

else:
return {"error": "Invalid shape type or missing required parameters"}

# The helpers produce origin-centered rings; offset them to the preview
# ra/dec so the axes read as sky coordinates.
vertices = [
[[request.ra + v[0], request.dec + v[1]] for v in ring] for ring in vertices
]

# Create a plotly figure
traces = []
for vert in vertices:
xs = [v[0] for v in vert]
ys = [v[1] for v in vert]
trace = go.Scatter(
x=xs, y=ys, line_color="blue", fill="tozeroy", fillcolor="violet"
x=xs,
y=ys,
mode="lines",
line_color="blue",
fill="toself",
fillcolor="violet",
)
traces.append(trace)

fig = go.Figure(data=traces)
# Lock a 1:1 data aspect ratio so footprints render true to shape.
# constrain="domain" must sit on the axis with excess space; the preview
# container is wider than tall, so it belongs on x. Setting it on both is
# robust to either orientation.
fig.update_layout(
showlegend=False,
xaxis_title="degrees",
yaxis_title="degrees",
xaxis=dict(constrain="domain"),
yaxis=dict(
matches="x",
scaleanchor="x",
scaleratio=1,
constrain="domain",
Expand Down
Loading
Loading