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
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,11 +63,11 @@ The folder for images can be configured via `appsettings.json` file in ImageMapp

## TODO

- Click to show full size image and optionally metadata details / path
- Show progress when loading images
- Abstract file enumeration and loading to allow varied sources not just a file folder
- Support multiple image folders
- CSS improvements - SASS and/or Blazor CSS isolation. Not embedded in JS
- Caching. Memory and/or stored cache of processed image metadata to speed up subsequent loads and reduce processing on each request. Would need to detect changes however.
- Configurable map tile provider options?
- UI improvements, filtering etc
- Error handling and logging improvements
- Container support
- Configurable map tile provider options?
- Support for varied image sources not just a file folder
7 changes: 7 additions & 0 deletions src/ImageMapper.Api/Controllers/ImagesController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@ public IAsyncEnumerable<ImageInfo> Get(CancellationToken ct)
return _svc.GetImagesAsync(ct);
}

[HttpGet("count")]
public async Task<ActionResult<int>> GetCount(CancellationToken ct)
{
var count = await _svc.GetImageCountAsync(ct);
return Ok(count);
}

[HttpGet("raw/{**relativePath}")]
public async Task<IActionResult> GetRaw(string relativePath, CancellationToken ct)
{
Expand Down
7 changes: 7 additions & 0 deletions src/ImageMapper.Api/Services/IImageService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,11 @@ public interface IImageService
/// null if the image could not be found.</returns>
/// <exception cref="PathTraversalException">Thrown when the provided relative path is invalid or attempts to traverse outside the allowed directory.</exception>"
Task<byte[]?> GetImageBytesAsync(string relativePath, CancellationToken ct = default);

/// <summary>
/// Asynchronously retrieves the total count of image files.
/// </summary>
/// <param name="ct">A cancellation token that can be used to cancel the operation.</param>
/// <returns>The total count of image files.</returns>
Task<int> GetImageCountAsync(CancellationToken ct = default);
}
21 changes: 16 additions & 5 deletions src/ImageMapper.Api/Services/ImageService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ public class ImageService : IImageService
private readonly IConfiguration _config;
private readonly string _imagesRoot;

private static readonly string[] ValidExtensions = [".jpg", ".jpeg", ".png", ".tif", ".tiff", ".nef"];

public ImageService(IConfiguration config)
{
_config = config;
Expand All @@ -25,10 +27,8 @@ public async IAsyncEnumerable<ImageInfo> GetImagesAsync([EnumeratorCancellation]
if (!System.IO.Directory.Exists(_imagesRoot))
yield break;

var extensions = new[] { ".jpg", ".jpeg", ".png", ".tif", ".tiff", ".nef" };

var files = System.IO.Directory.EnumerateFiles(_imagesRoot, "*.*", SearchOption.AllDirectories)
.Where(f => extensions.Contains(Path.GetExtension(f).ToLowerInvariant()));
.Where(f => ValidExtensions.Contains(Path.GetExtension(f).ToLowerInvariant()));

foreach (string f in files)
{
Expand Down Expand Up @@ -67,11 +67,11 @@ public async IAsyncEnumerable<ImageInfo> GetImagesAsync([EnumeratorCancellation]
var full = Path.Combine(_imagesRoot, normalized);
var fullPath = Path.GetFullPath(full);
var rootPath = Path.GetFullPath(_imagesRoot);

// Ensure rootPath ends with separator for proper boundary checking
if (!rootPath.EndsWith(Path.DirectorySeparatorChar))
rootPath += Path.DirectorySeparatorChar;

// Validate that the resolved path is within the root directory
if (!fullPath.StartsWith(rootPath, StringComparison.OrdinalIgnoreCase))
{
Expand All @@ -83,4 +83,15 @@ public async IAsyncEnumerable<ImageInfo> GetImagesAsync([EnumeratorCancellation]

return await File.ReadAllBytesAsync(fullPath, ct);
}

public Task<int> GetImageCountAsync(CancellationToken ct = default)
{
if (!System.IO.Directory.Exists(_imagesRoot))
return Task.FromResult(0);

var count = System.IO.Directory.EnumerateFiles(_imagesRoot, "*.*", SearchOption.AllDirectories)
.Count(f => ValidExtensions.Contains(Path.GetExtension(f).ToLowerInvariant()));

return Task.FromResult(count);
}
}
9 changes: 9 additions & 0 deletions src/ImageMapper.Web/Client/ImageItemFetcher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,15 @@ namespace ImageMapper.Web.Client
{
public class ImageItemFetcher(HttpClient httpClient)
{
/// <summary>
/// Fetch the total count of available images
/// </summary>
/// <returns>The total count of images</returns>
public async Task<int> FetchImageCount(CancellationToken ct = default)
{
return await httpClient.GetFromJsonAsync<int>("/api/images/count", ct);
}

/// <summary>
/// Fetch list of available images with metadata, streamed as async enumerable
/// </summary>
Expand Down
71 changes: 71 additions & 0 deletions src/ImageMapper.Web/Components/Pages/Home.razor
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,24 @@

<div id="map" style="height:700px;"></div>

<!-- Image count section -->
<div id="imageCountContainer" style="margin-top: 15px; padding: 10px; background-color: #f5f5f5; border-radius: 4px; display: none;">
<span id="imageCountText" style="font-size: 14px; color: #333;">Total images on map: <strong id="imageCountValue">0</strong></span>
<span id="skippedCountText" style="font-size: 14px; color: #666; margin-left: 20px; display: none;">Skipped: <strong id="skippedCountValue">0</strong></span>
</div>

<!-- Progress bar section -->
<div id="progressContainer" style="margin-top: 20px; display: none;">
<div style="display: flex; align-items: center; gap: 10px; margin-bottom: 10px;">
<span id="progressText" style="font-weight: 500; min-width: 150px;">Loading images...</span>
<div style="flex: 1; background-color: #e0e0e0; border-radius: 4px; height: 24px; overflow: hidden;">
<div id="progressBar" style="width: 0%; height: 100%; background-color: #4CAF50; transition: width 0.3s ease; display: flex; align-items: center; justify-content: center;">
<span id="progressPercentage" style="color: white; font-size: 12px; font-weight: bold;"></span>
</div>
</div>
</div>
</div>

<!-- Full-size image modal -->
<div id="imageModal" class="image-modal" style="display: none;">
<span class="close">&times;</span>
Expand Down Expand Up @@ -128,4 +146,57 @@
markerClusterGroup.addLayer(marker);
}
}

window.showProgressContainer = function() {
const container = document.getElementById('progressContainer');
if (container) {
container.style.display = 'block';
}
}

window.hideProgressContainer = function() {
const container = document.getElementById('progressContainer');
if (container) {
container.style.display = 'none';
}
}

window.updateImageCount = function(count, skipped = 0) {
const countValue = document.getElementById('imageCountValue');
const countContainer = document.getElementById('imageCountContainer');
const skippedCountText = document.getElementById('skippedCountText');
const skippedCountValue = document.getElementById('skippedCountValue');

if (countValue) {
countValue.textContent = count;
}
if (countContainer && count > 0) {
countContainer.style.display = 'block';
}

if (skipped > 0) {
if (skippedCountValue) {
skippedCountValue.textContent = skipped;
}
if (skippedCountText) {
skippedCountText.style.display = 'inline';
}
}
}

window.updateProgress = function(loaded, total, percentage) {
const progressBar = document.getElementById('progressBar');
const progressPercentage = document.getElementById('progressPercentage');
const progressText = document.getElementById('progressText');

if (progressBar) {
progressBar.style.width = percentage + '%';
}
if (progressPercentage) {
progressPercentage.textContent = percentage + '%';
}
if (progressText) {
progressText.textContent = `Loading images... (${loaded}/${total})`;
}
}
</script>
32 changes: 31 additions & 1 deletion src/ImageMapper.Web/Components/Pages/Home.razor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,23 @@ namespace ImageMapper.Web.Components.Pages
public partial class Home
{
private readonly CancellationTokenSource cts = new();
private int totalImages = 0;
private int skippedImages = 0;
private int imagesLoaded = 0;

protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
// Fetch total image count
totalImages = await imageFetcher.FetchImageCount(cts.Token);

// Show progress container if there are images
if (totalImages > 0)
{
await JS.InvokeVoidAsync("showProgressContainer");
}

// Initialize the map with cluster grouping
await JS.InvokeVoidAsync("initClusterMap", cts.Token);

Expand All @@ -19,8 +31,11 @@ protected override async Task OnAfterRenderAsync(bool firstRender)
int batchCount = 0;
await foreach (ImageInfo? image in imageFetcher.Fetch(cts.Token))
{
if (image == null)
if (image == null || image.Longitude == null || image.Latitude == null)
{
skippedImages++;
continue;
}

await JS.InvokeVoidAsync("addMarkerToMap",
new
Expand All @@ -31,13 +46,28 @@ await JS.InvokeVoidAsync("addMarkerToMap",
Url = $"/api/images/raw/{Uri.EscapeDataString(image.RelativePath)}"
});

imagesLoaded++;
batchCount++;

// Update progress bar
if (totalImages > 0)
{
int percentage = (int)((imagesLoaded * 100) / totalImages);
await JS.InvokeVoidAsync("updateProgress", imagesLoaded, totalImages, percentage);
}

if (batchCount % batchSize == 0)
{
StateHasChanged();
}
}

// Hide progress container when done
await JS.InvokeVoidAsync("hideProgressContainer");

// Update image count with skipped count if any
await JS.InvokeVoidAsync("updateImageCount", imagesLoaded, skippedImages);

// Final update to ensure UI is synchronized
StateHasChanged();
}
Expand Down