Skip to content

Commit

Permalink
Take Screenshot Implementation
Browse files Browse the repository at this point in the history
  • Loading branch information
TimPurdum committed Aug 26, 2024
1 parent e665fc8 commit 030d2a5
Show file tree
Hide file tree
Showing 7 changed files with 284 additions and 0 deletions.
1 change: 1 addition & 0 deletions src/dymaptic.GeoBlazor.Core.sln.DotSettings
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,7 @@
<s:String x:Key="/Default/CodeStyle/Generate/=Overrides/Options/=Async/@EntryIndexedValue">False</s:String>
<s:String x:Key="/Default/CodeStyle/Generate/=Overrides/Options/=Mutable/@EntryIndexedValue">False</s:String>
<s:Boolean x:Key="/Default/CodeStyle/Naming/CSharpAutoNaming/IsNotificationDisabled/@EntryValue">True</s:Boolean>
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=GIS/@EntryIndexedValue">GIS</s:String>
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=JSON/@EntryIndexedValue">JSON</s:String>
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/PredefinedNamingRules/=PrivateConstants/@EntryIndexedValue">&lt;Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /&gt;</s:String>
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/PredefinedNamingRules/=PrivateStaticReadonly/@EntryIndexedValue">&lt;Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /&gt;</s:String>
Expand Down
37 changes: 37 additions & 0 deletions src/dymaptic.GeoBlazor.Core/Components/Views/MapView.razor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2021,6 +2021,43 @@ await InvokeAsync(async () =>
});
}

/// <summary>
/// Create a screenshot of the current view. Screenshots include only elements that are rendered on the canvas (all geographical elements), but excludes overlayed DOM elements (UI, popups, measurement labels, etc.). By default, a screenshot of the whole view is created. Different options allow for creating different types of screenshots, including taking screenshots at different aspect ratios, different resolutions and creating thumbnails.
/// </summary>
/// <param name="options">
/// Optional settings for the screenshot.
/// </param>
/// <returns>
/// Returns a <see cref="Screenshot"/> which includes a Base64 data url as well as raw image data in a byte array.
/// </returns>
public async Task<Screenshot> TakeScreenshot(ScreenshotOptions? options = null)
{
try
{
JsScreenshot jsScreenshot = await ViewJsModule!.InvokeAsync<JsScreenshot>("takeScreenshot",
CancellationTokenSource.Token, Id, options);
Stream mapStream = await jsScreenshot.Stream.OpenReadStreamAsync(1_000_000_000L);
MemoryStream ms = new();
await mapStream.CopyToAsync(ms);
ms.Seek(0, SeekOrigin.Begin);
byte[] data = ms.ToArray();
string base64 =
$"data:image/{(options?.Format == ScreenshotFormat.Jpg ? "jpg" : "png")};base64,{Convert.ToBase64String(data)}";

Screenshot screenshot = new(base64, new ImageData(data, jsScreenshot.ColorSpace,
jsScreenshot.Width, jsScreenshot.Height));
await mapStream.DisposeAsync();
await ms.DisposeAsync();

return screenshot;
}
catch (Exception ex)
{
Console.WriteLine(ex);
throw;
}
}

#endregion

#region Lifecycle Methods
Expand Down
135 changes: 135 additions & 0 deletions src/dymaptic.GeoBlazor.Core/Objects/Screenshot.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
using dymaptic.GeoBlazor.Core.Serialization;
using Microsoft.JSInterop;
using System.Text.Json.Serialization;


namespace dymaptic.GeoBlazor.Core.Objects;

/// <summary>
/// Represents a screenshot of the map view.
/// </summary>
/// <param name="DataUrl">
/// The data url of the screenshot, beginning with `data:image/png:base64,`. Can be used as a source for an image element.
/// </param>
/// <param name="Data">
/// The <see cref="ImageData"/> for the screenshot.
/// </param>
public record Screenshot(string DataUrl, ImageData Data);

/// <summary>
/// Represents the image data of a screenshot.
/// </summary>
/// <param name="Data">
/// The image data as a byte array. Can be used with libraries such as ImageSharp or SkiaSharp to render or manipulate the image.
/// </param>
/// <param name="ColorSpace">
/// The color space of the image.
/// </param>
/// <param name="Height">
/// The height of the image.
/// </param>
/// <param name="Width">
/// The width of the image.
/// </param>
public record ImageData(byte[] Data, string ColorSpace, long Height, long Width);

/// <summary>
/// Internal representation of a screenshot, for passing from JavaScript.
/// </summary>
internal record JsScreenshot(IJSStreamReference Stream, long Height, long Width, string ColorSpace);

/// <summary>
/// Options for taking a screenshot
/// </summary>
public record ScreenshotOptions
{
/// <summary>
/// The format of the resulting encoded data url.
/// </summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public ScreenshotFormat? Format { get; init; }

/// <summary>
/// When used, only the visible layers with Ids in this list will be included in the output.
/// </summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public IReadOnlyCollection<Guid>? LayerIds { get; init; }

/// <summary>
/// The quality (0 to 100) of the encoded image when encoding as jpg.
/// </summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public int? Quality { get; init; }

/// <summary>
/// The width of the screenshot (defaults to the area width). The height will be derived automatically if left unspecified, according to the aspect ratio of the of the screenshot area.
/// </summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public int? Width { get; init; }

/// <summary>
/// The height of the screenshot (defaults to the area height). The width will be derived automatically if left unspecified, according to the aspect ratio of the screenshot area.
/// </summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public int? Height { get; init; }

/// <summary>
/// The area of the view to take a screenshot of.
/// </summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public ScreenshotArea? Area { get; init; }

/// <summary>
/// Indicates whether to ignore the background color set in the initial view properties of the web map.
/// </summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public bool? IgnoreBackground { get; init; }

/// <summary>
/// Indicates whether view padding should be ignored. Set this property to true to allow padded areas to be included in the screenshot.
/// </summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public bool? IgnorePadding { get; init; }
}

/// <summary>
/// Specifies whether to take a screenshot of a specific area of the view. The area coordinates are relative to the origin of the padded view and will be clipped to the view size. Defaults to the whole view.
/// </summary>
public record ScreenshotArea
{
/// <summary>
/// The x coordinate of the area.
/// </summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public double? X { get; init; }

/// <summary>
/// The y coordinate of the area.
/// </summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public double? Y { get; init; }

/// <summary>
/// The width of the area.
/// </summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public double? Width { get; init; }

/// <summary>
/// The height of the area.
/// </summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public double? Height { get; init; }
}

/// <summary>
/// The format of the resulting encoded data url.
/// </summary>
[JsonConverter(typeof(EnumToKebabCaseStringConverter<ScreenshotFormat>))]
public enum ScreenshotFormat
{
#pragma warning disable CS1591
Jpg,
Png
#pragma warning restore CS1591
}
38 changes: 38 additions & 0 deletions src/dymaptic.GeoBlazor.Core/Scripts/arcGisJsInterop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,7 @@ export let dotNetRefs = {};
export let queryLayer: FeatureLayer;
export let blazorServer: boolean = false;
import normalizeUtils from "@arcgis/core/geometry/support/normalizeUtils";
import Screenshot = __esri.Screenshot;
export { projection, geometryEngine, Graphic, Color, Point, Polyline, Polygon, normalizeUtils };
let notifyExtentChanged: boolean = true;
let uploadingLayers: Array<string> = [];
Expand Down Expand Up @@ -3343,4 +3344,41 @@ export function setStretchTypeForRenderer(rendererId, stretchType) {

export function getBrowserLanguage(): string {
return navigator.language;
}


export async function takeScreenshot(viewId, options): Promise<any> {
let view = arcGisObjectRefs[viewId] as MapView;
let screenshot: Screenshot;
if (hasValue(options)) {
if (hasValue(options.layerIds) && options.layerIds.length > 0) {
options.layers = options.layerIds.map(id => arcGisObjectRefs[id]);
delete options.layerIds;
}
screenshot = await view.takeScreenshot(options);
} else {
screenshot = await view.takeScreenshot();
}

let buffer = base64ToArrayBuffer(screenshot.dataUrl.split(",")[1]);

// @ts-ignore
let jsStreamRef = DotNet.createJSStreamReference(buffer);

return {
width: screenshot.data.width,
height: screenshot.data.height,
colorSpace: screenshot.data.colorSpace,
stream: jsStreamRef
};
}

// Converts a base64 string to an ArrayBuffer
function base64ToArrayBuffer(base64): Uint8Array {
const binaryString = atob(base64);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes;
}
Original file line number Diff line number Diff line change
Expand Up @@ -46,4 +46,66 @@
Assert.IsNotNull(result);
}

[TestMethod]
public async Task TestCanTakeScreenshot(Action renderHandler)
{
MapView? view = null;
AddMapRenderFragment(
@<MapView @ref="view" class="map-view" OnViewRendered="renderHandler">
<Map>
<Basemap>
<BasemapStyle Name="BasemapStyleName.ArcgisNavigation" />
</Basemap>
</Map>
</MapView>);
await WaitForMapToRender();

Screenshot screenshot = await view!.TakeScreenshot();
Assert.IsNotNull(screenshot);
Assert.IsNotNull(screenshot.DataUrl);
Assert.IsTrue(screenshot.DataUrl.StartsWith("data:image/png;base64,"));

AddMapRenderFragment(@<img id="screenshot1" src="@screenshot.DataUrl" alt="screenshot" />);
StateHasChanged();
await Task.Yield();
await AssertJavaScript("assertImageExists", args: ["screenshot1"]);
}

[TestMethod]
public async Task TestCanTakeScreenshotWithOptions(Action renderHandler)
{
MapView? view = null;
FeatureLayer? layer = null;
AddMapRenderFragment(
@<MapView @ref="view" class="map-view" OnViewRendered="renderHandler">
<Map>
<Basemap>
<BasemapStyle Name="BasemapStyleName.ArcgisNavigation" />
</Basemap>
<FeatureLayer @ref="layer" Title="Countries">
<PortalItem Id="ac80670eb213440ea5899bbf92a04998"/>
</FeatureLayer>
</Map>
</MapView>);
await WaitForMapToRender();

ScreenshotOptions options = new()
{
Width = 80,
Height = 80,
Format = ScreenshotFormat.Jpg,
LayerIds = [layer!.Id],
Quality = 50,
IgnoreBackground = true
};
Screenshot screenshot = await view!.TakeScreenshot(options);
Assert.IsNotNull(screenshot);
Assert.IsNotNull(screenshot.DataUrl);
Assert.IsTrue(screenshot.DataUrl.StartsWith("data:image/jpg;base64,"));

AddMapRenderFragment(@<img id="screenshot2" src="@screenshot.DataUrl" alt="screenshot" />);
StateHasChanged();
await Task.Yield();
await AssertJavaScript("assertImageExists", args: ["screenshot2"]);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
@using dymaptic.GeoBlazor.Core.Events
@using dymaptic.GeoBlazor.Core.Objects
@using dymaptic.GeoBlazor.Core.Model
@using Microsoft.AspNetCore.Components
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using Microsoft.Extensions.Configuration
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -219,4 +219,14 @@ export function getView(methodName) {
const mapContainer = testDiv.getElementsByClassName('map-container')[0];
const viewId = mapContainer.id.replace('map-container-', '');
return arcGisObjectRefs[viewId];
}

export function assertImageExists(methodName, elementId) {
let element = document.getElementById(elementId);
if (element === null) {
throw new Error(`Element with id ${elementId} does not exist`);
}
if (element.tagName !== 'IMG') {
throw new Error(`Element with id ${elementId} is not an image`);
}
}

0 comments on commit 030d2a5

Please sign in to comment.