Skip to content
Closed
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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@
"dompurify": "^3.4.0",
"geojson-vt": "^4.0.2",
"immutability-helper": "^3.1.1",
"jspdf": "^4.2.1",
"lodash.debounce": "^4.0.8",
"lodash.xor": "^4.5.0",
"lodash.xorby": "^4.7.0",
Expand Down
3 changes: 2 additions & 1 deletion src/api/mapguide-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@ export function initMapGuideCommands() {
enabled: state => !state.stateless,
invoke: (dispatch, getState, _viewer, parameters) => {
const config = getState().config;
const url = "component://QuickPlot";
const isClientSide = parameters?.ClientSide === "true";
const url = isClientSide ? "component://QuickPlot?clientSide=true" : "component://QuickPlot";
const cmdDef = buildTargetedCommand(config, parameters);
openUrlInTarget(DefaultCommands.QuickPlot, cmdDef, config.capabilities.hasTaskPane, dispatch, url);
}
Expand Down
77 changes: 68 additions & 9 deletions src/containers/quick-plot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,14 @@ function toggleMapCapturerLayer(locale: string,
}

export interface IQuickPlotContainerOwnProps {

/**
* When set to "true", the QuickPlot component operates in fully client-side mode
* and generates the PDF locally without requiring a MapGuide Server connection.
* This value is read from the widget's Extension.ClientSide property in the appdef.
*
* @since 0.15
*/
clientSide?: string;
}

export interface IQuickPlotContainerConnectedState {
Expand Down Expand Up @@ -195,9 +202,10 @@ export interface IQuickPlotContainerState {
normalizedBox: string;
}

export const QuickPlotContainer = () => {
export const QuickPlotContainer = (props: IQuickPlotContainerOwnProps) => {
const isClientSide = props.clientSide === "true";
const { Slider, Callout, Button, Select, FormGroup, InputGroup, Checkbox } = useElementContext();
const [title, setTitle] = React.useState(""); ``
const [title, setTitle] = React.useState("");
const [subTitle, setSubTitle] = React.useState("");
const [showLegend, setShowLegend] = React.useState(false);
const [showNorthBar, setShowNorthBar] = React.useState(false);
Expand All @@ -212,6 +220,7 @@ export const QuickPlotContainer = () => {
const [rotation, setRotation] = React.useState(0);
const [box, setBox] = React.useState("");
const [normalizedBox, setNormalizedBox] = React.useState("");
const [isGenerating, setIsGenerating] = React.useState(false);

const viewer = useMapProviderContext();
const locale = useViewerLocale();
Expand Down Expand Up @@ -251,7 +260,50 @@ export const QuickPlotContainer = () => {
const onRotationChanged = (value: number) => {
setRotation(value);
};
const onGeneratePlot = () => { };
const onGeneratePlot = (e: React.MouseEvent<HTMLButtonElement>) => {
if (!isClientSide) return;
e.preventDefault();
const tokens = paperSize.split(",");
const baseW = parseFloat(tokens[0]);
const baseH = parseFloat(tokens[1]);
setIsGenerating(true);
viewer.exportImage({
callback: async (imageData) => {
try {
const { jsPDF } = await import("jspdf");
const doc = new jsPDF({
orientation: orientation === "P" ? "p" : "l",
unit: "mm",
format: [baseW, baseH]
});
const pageW = doc.internal.pageSize.getWidth();
const pageH = doc.internal.pageSize.getHeight();
const margins = getMargin();
let yPos = margins.top / 2;
if (title) {
doc.setFontSize(16);
doc.text(title, pageW / 2, yPos + 8, { align: "center" });
yPos += 14;
}
if (subTitle) {
doc.setFontSize(12);
doc.text(subTitle, pageW / 2, yPos + 4, { align: "center" });
yPos += 10;
}
const mapLeft = margins.left;
const mapTop = yPos;
const mapWidth = pageW - margins.left - margins.right;
const mapHeight = pageH - mapTop - margins.buttom;
doc.addImage(imageData, "PNG", mapLeft, mapTop, mapWidth, mapHeight);
doc.save("quickplot.pdf");
} catch (err) {
console.error("QuickPlot client-side PDF generation failed:", err);
} finally {
setIsGenerating(false);
}
}
});
};
const updateBoxCoords = (box: string, normalizedBox: string): void => {
setBox(box);
setNormalizedBox(normalizedBox);
Expand Down Expand Up @@ -305,7 +357,10 @@ export const QuickPlotContainer = () => {
}
}
}, [mapNames, activeMapName, showAdvanced, scale, paperSize, orientation, rotation, locale]);
if (!viewer.isReady() || !map || !view) {
if (!viewer.isReady() || !view) {
return <noscript />;
}
if (!isClientSide && !map) {
return <noscript />;
}
let hasExternalBaseLayers = false;
Expand Down Expand Up @@ -335,7 +390,7 @@ export const QuickPlotContainer = () => {
{ value: "L" as Orientation, label: xlate("QUICKPLOT_ORIENTATION_L", locale) }
];
return <div className="component-quick-plot">
<form id="Form1" name="Form1" target="_blank" method="post" action={url}>
<form id="Form1" name="Form1" target={isClientSide ? undefined : "_blank"} method={isClientSide ? undefined : "post"} action={isClientSide ? undefined : url}>
<input type="hidden" id="printId" name="printId" value={`${Math.random() * 1000}`} />
<div className="Title FixWidth">{xlate("QUICKPLOT_HEADER", locale)}</div>
<FormGroup label={xlate("QUICKPLOT_TITLE", locale)}>
Expand Down Expand Up @@ -430,13 +485,17 @@ export const QuickPlotContainer = () => {
}
})()}
<div className="ButtonContainer FixWidth">
<Button type="submit" variant="primary" icon="print" onClick={onGeneratePlot}>{xlate("QUICKPLOT_GENERATE", locale)}</Button>
<Button type={isClientSide ? "button" : "submit"} variant="primary" icon="print" onClick={onGeneratePlot} disabled={isGenerating}>
{isGenerating ? xlate("QUICKPLOT_GENERATING", locale) : xlate("QUICKPLOT_GENERATE", locale)}
</Button>
</div>
<input type="hidden" id="margin" name="margin" />
<input type="hidden" id="normalizedBox" name="normalizedBox" value={normBox} />
<input type="hidden" id="rotation" name="rotation" value={-(rotation || 0)} />
<input type="hidden" id="sessionId" name="sessionId" value={map.SessionId} />
<input type="hidden" id="mapName" name="mapName" value={map.Name} />
{!isClientSide && <>
<input type="hidden" id="sessionId" name="sessionId" value={map?.SessionId} />
<input type="hidden" id="mapName" name="mapName" value={map?.Name} />
</>}
<input type="hidden" id="box" name="box" value={theBox} />
<input type="hidden" id="legalNotice" name="legalNotice" />
</form>
Expand Down
1 change: 1 addition & 0 deletions src/strings/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ export const STRINGS_EN: ILocalizedMessages = {
"QUICKPLOT_BOX_INFO": "Quick Plot Map Capture box is active. Map rotation is disabled while box is active",
"QUICKPLOT_BOX_ROTATION": "Capture Box Rotation",
"QUICKPLOT_GENERATE": "Generate Plot",
"QUICKPLOT_GENERATING": "Generating PDF...",
"QUICKPLOT_COMMERCIAL_LAYER_WARNING": "Quick Plot will NOT include any visible commercial map layers",
"FEATURE_TOOLTIPS": "Feature Tooltips",
"MANUAL_FEATURE_TOOLTIPS": "Manual Feature Tooltips (click to show)",
Expand Down
1 change: 1 addition & 0 deletions src/strings/msgdef.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ export interface ILocalizedMessages {
QUICKPLOT_BOX_INFO: string;
QUICKPLOT_BOX_ROTATION: string;
QUICKPLOT_GENERATE: string;
QUICKPLOT_GENERATING: string;
QUICKPLOT_COMMERCIAL_LAYER_WARNING: string;
FEATURE_TOOLTIPS: string;
MANUAL_FEATURE_TOOLTIPS: string;
Expand Down
74 changes: 73 additions & 1 deletion test/containers/neo-and-quick-plot.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,28 @@ const swipeMock = vi.hoisted(() => ({
useMapSwipeInfo: vi.fn(),
}));

const jspdfMock = vi.hoisted(() => ({
save: vi.fn(),
addImage: vi.fn(),
setFontSize: vi.fn(),
text: vi.fn(),
}));

vi.mock("jspdf", () => ({
jsPDF: vi.fn().mockImplementation(() => ({
internal: {
pageSize: {
getWidth: () => 210,
getHeight: () => 297,
}
},
setFontSize: jspdfMock.setFontSize,
text: jspdfMock.text,
addImage: jspdfMock.addImage,
save: jspdfMock.save,
}))
}));

vi.mock("../../src/containers/hooks", () => hooksMock);
vi.mock("../../src/containers/hooks-mapguide", () => hooksMapGuideMock);
vi.mock("../../src/components/map-providers/context", () => mapProviderCtxMock);
Expand Down Expand Up @@ -120,7 +142,7 @@ vi.mock("../../src/components/elements/element-context", () => ({
/>
),
Callout: ({ children }: React.PropsWithChildren<{}>) => <div data-testid="callout">{children}</div>,
Button: ({ children }: React.PropsWithChildren<{}>) => <button>{children}</button>,
Button: ({ children, onClick, type, disabled }: { children?: React.ReactNode; onClick?: React.MouseEventHandler<HTMLButtonElement>; type?: "button" | "reset" | "submit"; disabled?: boolean }) => <button onClick={onClick} type={type} disabled={disabled}>{children}</button>,
Select: () => <select />,
Toaster: React.forwardRef((_props: any, _ref: any) => <div data-testid="toaster" />),
Checkbox: ({ label, checked, onChange, id, name }: any) => (
Expand Down Expand Up @@ -438,4 +460,54 @@ describe("neo-map-viewer and quick-plot", () => {
});
expect((container.querySelector("#rotation") as HTMLInputElement).value).toBe("-15");
});

it("renders QuickPlotContainer in client-side mode without a MapGuide map", () => {
const viewer = {
isReady: () => true,
getCurrentExtent: () => [0, 0, 100, 100],
exportImage: vi.fn(),
};
mapProviderCtxMock.useMapProviderContext.mockReturnValue(viewer);
hooksMapGuideMock.useActiveMapState.mockReturnValue(undefined);
hooksMock.useActiveMapView.mockReturnValue({ scale: 5000 });
hooksMock.useActiveMapExternalBaseLayers.mockReturnValue([]);
hooksMock.useAvailableMaps.mockReturnValue([{ name: "Map1", value: "Map1" }]);
hooksMock.useActiveMapName.mockReturnValue("Map1");

const { container } = render(<QuickPlotContainer clientSide="true" />);

expect(container.querySelector(".component-quick-plot")).toBeTruthy();
expect(container.querySelector("input[name='sessionId']")).toBeNull();
expect(container.querySelector("input[name='mapName']")).toBeNull();
const btn = container.querySelector("button");
expect(btn?.getAttribute("type")).toBe("button");
});

it("calls exportImage and generates PDF in client-side mode when Generate is clicked", async () => {
const exportImage = vi.fn((opts: any) => {
opts.callback("data:image/png;base64,ABC123");
});
const viewer = {
isReady: () => true,
getCurrentExtent: () => [0, 0, 100, 100],
exportImage,
};
mapProviderCtxMock.useMapProviderContext.mockReturnValue(viewer);
hooksMapGuideMock.useActiveMapState.mockReturnValue(undefined);
hooksMock.useActiveMapView.mockReturnValue({ scale: 5000 });
hooksMock.useActiveMapExternalBaseLayers.mockReturnValue([]);
hooksMock.useAvailableMaps.mockReturnValue([{ name: "Map1", value: "Map1" }]);
hooksMock.useActiveMapName.mockReturnValue("Map1");

const { container } = render(<QuickPlotContainer clientSide="true" />);

const btn = container.querySelector("button") as HTMLButtonElement;
fireEvent.click(btn);

expect(exportImage).toHaveBeenCalled();
await waitFor(() => {
expect(jspdfMock.addImage).toHaveBeenCalledWith("data:image/png;base64,ABC123", "PNG", expect.any(Number), expect.any(Number), expect.any(Number), expect.any(Number));
expect(jspdfMock.save).toHaveBeenCalledWith("quickplot.pdf");
});
});
});
Loading
Loading