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
15 changes: 0 additions & 15 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions packages/app/src/components/data/plot/definition/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,8 +103,8 @@ export function PlotLayout({

// Calculate available space considering sidebar
const sidebarWidth = sidebarOpen ? 600 : 0; // Maximum sidebar width
const availableWidth = containerWidth - sidebarWidth - 64; // 64px for padding
const availableHeight = containerHeight - 64; // 64px for padding
const availableWidth = containerWidth - sidebarWidth - 32; // 32px for padding
const availableHeight = containerHeight - 32; // 32px for padding

if (!plot.definition) return;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { toast } from "sonner";
import { updatePlotDefinitionAction } from "@/actions/data/plots/updatePlotDefinition";
import { LabelWithBadge } from "../../form/label-badge";
Expand Down Expand Up @@ -45,6 +46,31 @@ type DataSource =
};

export function GeneralSection({ plot, queries, tables }: GeneralSectionProps) {
const handleShowFrameChange = async (showFrame: boolean) => {
try {
const currentDefinition = plot.definition;
if (!currentDefinition) return;

const updatedDefinition = {
...currentDefinition,
appearance: {
...currentDefinition.appearance,
showFrame,
},
} as PlotDefinition;

await updatePlotDefinitionAction(
plot.id,
updatedDefinition,
plot.projectId,
);
toast.success("Frame setting updated");
} catch (error) {
console.error("Failed to update frame setting:", error);
toast.error("Failed to update frame setting");
}
};

const handlePlotTypeChange = async (type: PlotDefinition["type"]) => {
try {
// Get the default definition for the new type
Expand Down Expand Up @@ -209,6 +235,23 @@ export function GeneralSection({ plot, queries, tables }: GeneralSectionProps) {
/>
</div>
</div>

<div className="mt-6 space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<LabelWithBadge
isValid={true}
description="Show or hide the plot frame border"
>
Show frame
</LabelWithBadge>
</div>
<Switch
checked={plot.definition?.appearance?.showFrame ?? true}
onCheckedChange={handleShowFrameChange}
/>
</div>
</div>
</SectionWrapper>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { Plus, Trash2 } from "lucide-react";
import { type PlotDefinition } from "@common/db/schema/plot";
import { isDefined } from "@/utils/helpers";
import { ColorPicker } from "@/components/ui/color-picker";
import { usePlotTheme } from "@/hooks/use-plot-theme";

interface TextLabelsFormProps {
onSubmit: (_data: PlotDefinition) => void;
Expand All @@ -16,6 +17,7 @@ interface TextLabelsFormProps {
export function TextLabelsForm({ onSubmit }: TextLabelsFormProps) {
const { watch, setValue, handleSubmit } = useFormContext<PlotDefinition>();
const textLabels = watch("textLabels") ?? [];
const theme = usePlotTheme();

const handleAddLabel = () => {
const newLabels = [
Expand All @@ -25,7 +27,7 @@ export function TextLabelsForm({ onSubmit }: TextLabelsFormProps) {
y: 0,
text: "new label",
color: "#000000",
fontSize: 12,
fontSize: theme.fonts.annotation,
rotation: 0,
},
];
Expand Down Expand Up @@ -163,7 +165,7 @@ export function TextLabelsForm({ onSubmit }: TextLabelsFormProps) {
type="number"
min="8"
max="72"
value={textLabels[index]?.fontSize ?? 12}
value={textLabels[index]?.fontSize ?? theme.fonts.annotation}
onChange={(e) =>
handleLabelChange(index, "fontSize", e.target.value)
}
Expand Down
189 changes: 185 additions & 4 deletions packages/app/src/components/data/plot/plot-export/utils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { PLOT_FONT_CONFIG } from "@/hooks/use-plot-theme";

export function getPlotSVG(container: HTMLElement): SVGElement {
// Get the main plot SVG that contains everything
const svg = container.querySelector(
Expand All @@ -8,21 +10,152 @@ export function getPlotSVG(container: HTMLElement): SVGElement {
throw new Error("Plot SVG not found");
}

// Clone the SVG to avoid modifying the original
const clonedSvg = svg.cloneNode(true) as SVGElement;

// Get the original dimensions
const box = svg.getBoundingClientRect();

// Clone the SVG to avoid modifying the original
const clonedSvg = svg.cloneNode(true) as SVGElement;

// Set the dimensions explicitly
clonedSvg.setAttribute("width", String(box.width));
clonedSvg.setAttribute("height", String(box.height));
clonedSvg.setAttribute("viewBox", `0 0 ${box.width} ${box.height}`);

// Apply explicit font sizes to ensure they're preserved
applyExplicitFontSizes(clonedSvg);

// Also ensure all text has proper font family and other properties
ensureTextProperties(clonedSvg);

return clonedSvg;
}

function applyExplicitFontSizes(svg: SVGElement): void {
// Find all text elements and apply explicit font sizes using inline styles
const textElements = svg.querySelectorAll("text, tspan");

textElements.forEach((element) => {
const textElement = element as SVGTextElement;

// Get current style attribute or create empty one
let currentStyle = textElement.getAttribute("style") || "";

// Use data attributes for robust identification
const plotElement = textElement.getAttribute("data-plot-element");

let targetFontSize = `${PLOT_FONT_CONFIG.axisTick}px`; // Default

// Identify element type using data attributes and class names
if (plotElement === "axis-label") {
targetFontSize = `${PLOT_FONT_CONFIG.axisLabel}px`;
} else if (plotElement === "axis-tick") {
targetFontSize = `${PLOT_FONT_CONFIG.axisTick}px`;
} else if (plotElement === "legend-title") {
targetFontSize = `${PLOT_FONT_CONFIG.legendTitle}px`;
} else if (plotElement === "legend-item") {
targetFontSize = `${PLOT_FONT_CONFIG.legendItem}px`;
} else if (plotElement === "text-annotation") {
targetFontSize = `${PLOT_FONT_CONFIG.annotation}px`;
} else {
// Fallback to class name identification
const hasAxisLabelClass =
textElement.classList.contains("plot-axis-label");
const hasAxisTickClass = textElement.classList.contains("plot-axis-tick");

if (hasAxisLabelClass) {
targetFontSize = `${PLOT_FONT_CONFIG.axisLabel}px`;
} else if (hasAxisTickClass) {
targetFontSize = `${PLOT_FONT_CONFIG.axisTick}px`;
} else {
// Default fallback - ensure minimum font size
const hasFontSize = textElement.getAttribute("fontSize");
if (hasFontSize) {
const currentSize = parseInt(hasFontSize);
const minSize = PLOT_FONT_CONFIG.axisTick;
targetFontSize =
currentSize < minSize ? `${minSize}px` : `${currentSize}px`;
}
}
}

// Remove any existing font-size from style attribute
currentStyle = currentStyle.replace(/font-size\s*:\s*[^;]+;?/g, "");

// Add the new font-size to the style attribute
if (currentStyle && !currentStyle.endsWith(";")) {
currentStyle += ";";
}
currentStyle += `font-size: ${targetFontSize};`;

// Set the updated style attribute
textElement.setAttribute("style", currentStyle);

// Also set fontSize attribute as backup
textElement.setAttribute("fontSize", targetFontSize.replace("px", ""));
});

// Find axis labels using data attributes and class names
const axisLabels = svg.querySelectorAll(
// eslint-disable-next-line quotes
'[data-plot-element="axis-label"], .plot-axis-label',
);
axisLabels.forEach((element) => {
const textElement = element as SVGTextElement;
if (textElement.tagName === "text" || textElement.tagName === "tspan") {
let currentStyle = textElement.getAttribute("style") || "";
currentStyle = currentStyle.replace(/font-size\s*:\s*[^;]+;?/g, "");
if (currentStyle && !currentStyle.endsWith(";")) {
currentStyle += ";";
}
currentStyle += `font-size: ${PLOT_FONT_CONFIG.axisLabel}px;`;
textElement.setAttribute("style", currentStyle);
textElement.setAttribute("fontSize", String(PLOT_FONT_CONFIG.axisLabel));
}
});
}

function ensureTextProperties(svg: SVGElement): void {
// Find all text elements and ensure they have proper font properties
const textElements = svg.querySelectorAll("text, tspan");

textElements.forEach((element) => {
const textElement = element as SVGTextElement;
let currentStyle = textElement.getAttribute("style") || "";

// Ensure font-family is set (use system default if not specified)
if (!currentStyle.includes("font-family")) {
if (currentStyle && !currentStyle.endsWith(";")) {
currentStyle += ";";
}
currentStyle +=
"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;";
}

// Ensure font-weight is set for better rendering
if (!currentStyle.includes("font-weight")) {
if (currentStyle && !currentStyle.endsWith(";")) {
currentStyle += ";";
}
currentStyle += "font-weight: 400;";
}

// Ensure text-rendering is optimized
if (!currentStyle.includes("text-rendering")) {
if (currentStyle && !currentStyle.endsWith(";")) {
currentStyle += ";";
}
currentStyle += "text-rendering: optimizeLegibility;";
}

// Set the updated style attribute
textElement.setAttribute("style", currentStyle);
});
}

export function svgToDataURL(svg: SVGElement): string {
// Add font definitions to ensure fonts are preserved
addFontDefinitions(svg);

const serializer = new XMLSerializer();
const svgString = serializer.serializeToString(svg);
const svgBlob = new Blob([svgString], {
Expand All @@ -31,12 +164,56 @@ export function svgToDataURL(svg: SVGElement): string {
return URL.createObjectURL(svgBlob);
}

function addFontDefinitions(svg: SVGElement): void {
// Get the document's font information
const fontFaces = Array.from(document.fonts)
.map((font) => {
const family = font.family;
const style = font.style;
const weight = font.weight;
const stretch = font.stretch;
const unicodeRange = font.unicodeRange;

return `@font-face {
font-family: "${family}";
font-style: ${style};
font-weight: ${weight};
font-stretch: ${stretch};
unicode-range: ${unicodeRange};
src: local("${family}");
}`;
})
.join("\n");

// Add a style element with font definitions
if (fontFaces) {
const styleElement = document.createElementNS(
"http://www.w3.org/2000/svg",
"style",
);
styleElement.textContent = fontFaces;

// Insert at the beginning of the SVG
const defs =
svg.querySelector("defs") ||
svg.insertBefore(
document.createElementNS("http://www.w3.org/2000/svg", "defs"),
svg.firstChild,
);
defs.appendChild(styleElement);
}
}

export async function svgToPngDataURL(
svg: SVGElement,
scale = 2,
backgroundColor = "white",
): Promise<string> {
return new Promise((resolve, reject) => {
// Ensure font sizes are explicitly set before converting to PNG
applyExplicitFontSizes(svg);
ensureTextProperties(svg);
addFontDefinitions(svg);
const svgURL = svgToDataURL(svg);
const img = new Image();
const width = parseFloat(svg.getAttribute("width") || "0");
Expand All @@ -53,6 +230,10 @@ export async function svgToPngDataURL(
throw new Error("Failed to get canvas context");
}

// Set high quality rendering
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = "high";

// Fill background if specified
if (backgroundColor) {
ctx.fillStyle = backgroundColor;
Expand All @@ -64,7 +245,7 @@ export async function svgToPngDataURL(
ctx.drawImage(img, 0, 0);

// Convert to data URL
const dataURL = canvas.toDataURL("image/png");
const dataURL = canvas.toDataURL("image/png", 1.0); // Maximum quality
URL.revokeObjectURL(svgURL);
resolve(dataURL);
} catch (error) {
Expand Down
Loading