First time back after a while. Rolled back some changes that made the app inoperable (specifically NWSWeatherService). Temp Blanket visualizer works, including color editing for ranges. Need to plug in real data.

This commit is contained in:
Kira Jiroux 2025-06-02 17:12:40 -04:00
parent bdd0f2e8bb
commit 764c094a64
16 changed files with 512 additions and 1 deletions

View File

@ -1,5 +1,6 @@
using Microsoft.Extensions.DependencyInjection;
using Portfolio.Application.Services.Articles;
using Portfolio.Application.Services.NWSWeatherService;
using Portfolio.Application.Services.PokemonNatureService;
using Portfolio.Application.Services.PokemonService;
using Portfolio.Application.Services.PokemonSubskillService;
@ -19,6 +20,7 @@ namespace Portfolio.Application
services.AddScoped<IPokemonService, PokemonService>();
services.AddScoped<IPokemonSubskillService, PokemonSubskillService>();
services.AddScoped<IPokemonNatureService, PokemonNatureService>();
//services.AddScoped<INWSWeatherService, NWSWeatherService>();

View File

@ -0,0 +1,17 @@
using Portfolio.Domain.Features.TemperatureDay;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Portfolio.Application.Services.NWSWeatherService
{
public interface INWSWeatherService
{
Task<string> GetNearestStationAsync(double latitude, double longitude);
Task<double?> GetDailyAverageTempAsync(string stationId, DateTime date);
Task<List<TemperatureDay>> GetTemperatureDataAsync(double latitude, double longitude, int year);
}
}

View File

@ -0,0 +1,123 @@
using Portfolio.Domain.Features.TemperatureDay;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
namespace Portfolio.Application.Services.NWSWeatherService
{
public class NWSWeatherService : INWSWeatherService
{
private readonly HttpClient _httpClient;
public NWSWeatherService(HttpClient httpClient)
{
_httpClient = httpClient;
_httpClient.DefaultRequestHeaders.Add("User-Agent", "kira.jiroux@gmail.com"); // Replace with your email ideally
}
public async Task<string> GetNearestStationAsync(double latitude, double longitude)
{
var url = $"https://api.weather.gov/points/{latitude},{longitude}";
var response = await _httpClient.GetAsync(url);
response.EnsureSuccessStatusCode();
using var stream = await response.Content.ReadAsStreamAsync();
var json = await JsonDocument.ParseAsync(stream);
var stationUrl = json.RootElement
.GetProperty("properties")
.GetProperty("observationStations")
.GetString();
if (string.IsNullOrEmpty(stationUrl))
throw new Exception("Could not find station info.");
var stationListResponse = await _httpClient.GetAsync(stationUrl);
stationListResponse.EnsureSuccessStatusCode();
using var stationStream = await stationListResponse.Content.ReadAsStreamAsync();
var stationJson = await JsonDocument.ParseAsync(stationStream);
var firstStation = stationJson.RootElement
.GetProperty("features")[0]
.GetProperty("properties")
.GetProperty("stationIdentifier")
.GetString();
return firstStation;
}
public async Task<double?> GetDailyAverageTempAsync(string stationId, DateTime date)
{
var start = date.ToString("yyyy-MM-dd") + "T00:00:00Z";
var end = date.ToString("yyyy-MM-dd") + "T23:59:59Z";
var url = $"https://api.weather.gov/stations/{stationId}/observations?start={start}&end={end}";
var response = await _httpClient.GetAsync(url);
response.EnsureSuccessStatusCode();
using var stream = await response.Content.ReadAsStreamAsync();
var json = await JsonDocument.ParseAsync(stream);
var temps = new List<double>();
foreach (var feature in json.RootElement.GetProperty("features").EnumerateArray())
{
if (feature.TryGetProperty("properties", out var props) &&
props.TryGetProperty("temperature", out var tempObj) &&
tempObj.TryGetProperty("value", out var valueProp) &&
valueProp.ValueKind == JsonValueKind.Number)
{
var celsius = valueProp.GetDouble();
if (!double.IsNaN(celsius))
{
var fahrenheit = (celsius * 9.0 / 5.0) + 32.0;
temps.Add(fahrenheit);
}
}
}
if (temps.Count == 0)
return null;
return temps.Average();
}
public async Task<List<TemperatureDay>> GetTemperatureDataAsync(double latitude, double longitude, int year)
{
var stationId = await GetNearestStationAsync(latitude, longitude);
var result = new List<TemperatureDay>();
var startDate = new DateTime(year, 1, 1);
var endDate = new DateTime(year, 12, 31);
for (var date = startDate; date <= endDate; date = date.AddDays(1))
{
Console.WriteLine($"Fetching {date:yyyy-MM-dd}...");
var avgTemp = await GetDailyAverageTempAsync(stationId, date);
if (avgTemp.HasValue)
{
result.Add(new TemperatureDay
{
Date = date,
AvgTemp = Math.Round(avgTemp.Value, 1)
});
}
else
{
Console.WriteLine($"No data for {date:yyyy-MM-dd}");
}
await Task.Delay(600); // small delay to be nice to NWS
}
return result;
}
}
}

View File

@ -0,0 +1,14 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Portfolio.Domain.Features.TemperatureDay
{
public class TemperatureDay
{
public DateTime Date { get; set; }
public double AvgTemp { get; set; }
}
}

View File

@ -0,0 +1,15 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Portfolio.Domain.Features.TemperatureRange
{
public class TemperatureRange
{
public double Min { get; set; }
public double Max { get; set; }
public string Color { get; set; } = "#ffffff";
}
}

View File

@ -2,6 +2,7 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Portfolio.Application.Services.Articles;
using Portfolio.Application.Services.NWSWeatherService;
using Portfolio.Application.Services.PokemonService;
using Portfolio.Domain.Features.Pokemon;
using Portfolio.Domain.Features.Pokemon_Natures;
@ -25,6 +26,7 @@ namespace Portfolio.Infrastructure
services.AddScoped<IPokemonRepository, PokemonRepository>();
services.AddScoped<IPokemonNatureRepository, PokemonNatureRepository>();
services.AddScoped<IPokemonSubskillRepository, PokemonSubskillRepository>();
//services.AddScoped<INWSWeatherService, NWSWeatherService>();
return services;
}

View File

@ -0,0 +1,29 @@
@attribute [StreamRendering]
@rendermode InteractiveServer
<div>
@if (TemperatureDays is null || TemperatureRanges is null)
{
<Loading />
}
else
{
<div>
<h3 class="text-xl font-bold mb-4">Temperature Blanket Reviewer</h3>
<div style="display: flex; overflow-x: auto; background-color: black; padding: 10px;">
@foreach (var day in TemperatureDays)
{
var color = GetColorForTemp(day.AvgTemp);
<div style="width: 6px; height: 600px; background-color:@color; margin-right: 1px;"
title="@day.Date.ToString("MMM dd") - @day.AvgTemp°F (@color)">
</div>
}
</div>
<TemperatureRangeEditor TempRanges="@TemperatureRanges" OnRangesChanged="HandleRangesChanged" />
</div>
}
</div>

View File

@ -0,0 +1,46 @@
using Microsoft.AspNetCore.Components;
using Portfolio.Domain.Features.TemperatureDay;
using Portfolio.Domain.Features.TemperatureRange;
namespace Portfolio.WebUI.Server.Components.Component.Crochet_Components
{
public partial class TemperatureBlanketVisualizer : ComponentBase
{
[Parameter] public List<TemperatureDay> TemperatureDays { get; set; }
public List<TemperatureRange> TemperatureRanges { get; set; } = new();
protected override void OnInitialized()
{
TemperatureRanges = new()
{
new() { Min = 0, Max = 21, Color = "#ffffff" },
new() { Min = 21, Max = 28, Color = "#ffc0cb" },
new() { Min = 28, Max = 35, Color = "#dda0dd" },
new() { Min = 35, Max = 42, Color = "#add8e6" },
new() { Min = 42, Max = 49, Color = "#00008b" },
new() { Min = 49, Max = 56, Color = "#006400" },
new() { Min = 56, Max = 63, Color = "#90ee90" },
new() { Min = 63, Max = 70, Color = "#ffff00" },
new() { Min = 70, Max = 77, Color = "#ffa500" },
new() { Min = 77, Max = 84, Color = "#ff0000" },
new() { Min = 84, Max = 100, Color = "#000000" }
};
}
private string GetColorForTemp(double temp)
{
var range = TemperatureRanges.FirstOrDefault(r => temp >= r.Min && temp < r.Max);
return range?.Color ?? "#888888";
}
private void HandleRangesChanged(List<TemperatureRange> updatedRanges)
{
TemperatureRanges = updatedRanges;
StateHasChanged();
}
}
}

View File

@ -0,0 +1,34 @@
@using Microsoft.AspNetCore.Components.Web
@attribute [StreamRendering]
@rendermode InteractiveServer
<div class="relative w-full h-24 px-4" @onmouseup="OnMouseUp" @onmousemove="OnMouseMove">
<!-- Base number line -->
<div class="absolute top-1/2 left-0 right-0 h-1 bg-gray-300 transform -translate-y-1/2"></div>
<!-- Draggable nodes for adjusting range breakpoints -->
@for (int i = 0; i < TempRanges.Count; i++)
{
var left = i == 0 ? 0 : TempRanges[i - 1].Max;
var leftPercent = (left / 100.0) * 100;
<div class="absolute top-1/2 transform -translate-y-1/2 -translate-x-1/2 w-4 h-4 rounded-full bg-blue-600 cursor-pointer"
style="left: @leftPercent%"
@onmousedown="(e) => OnMouseDown(i, e)"
title="@left°F">
</div>
}
<!-- Color pickers for each range -->
<div class="flex gap-2 mt-6">
@for (int i = 0; i < TempRanges.Count; i++)
{
var localIndex = i;
<input type="color"
value="@TempRanges[i].Color"
@onchange="e => HandleColorChange(e, localIndex)" />
}
</div>
</div>

View File

@ -0,0 +1,88 @@
using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
using Portfolio.Domain.Features.TemperatureRange;
namespace Portfolio.WebUI.Server.Components.Component.Crochet_Components
{
public partial class TemperatureRangeEditor : ComponentBase
{
[Parameter] public List<TemperatureRange> TempRanges { get; set; }
[Parameter] public EventCallback<List<TemperatureRange>> OnRangesChanged { get; set; }
[Inject] private IJSRuntime JS { get; set; }
private double windowWidth = 1000;
private int? draggingIndex = null;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
windowWidth = await JS.InvokeAsync<double>("eval", "window.innerWidth");
StateHasChanged();
}
}
private void OnMouseDown(int index, MouseEventArgs e)
{
draggingIndex = index;
}
private async void OnMouseUp()
{
if (draggingIndex != null)
{
draggingIndex = null;
await OnRangesChanged.InvokeAsync(TempRanges);
}
}
private void OnMouseMove(MouseEventArgs e)
{
if (draggingIndex is not int index || index <= 0 || index >= TempRanges.Count)
return;
var percent = (e.ClientX / windowWidth) * 100;
percent = Math.Clamp(percent, 0, 100);
var min = TempRanges[index - 1].Min;
var max = TempRanges[index].Max;
if (percent <= min + 1 || percent >= max - 1)
return;
TempRanges[index - 1].Max = percent;
TempRanges[index].Min = percent;
}
private void HandleColorChange(ChangeEventArgs e, int index)
{
if (index < 0 || index >= TempRanges.Count)
{
Console.WriteLine($"Color change requested for out-of-bounds index: {index}");
return;
}
var color = e?.Value?.ToString() ?? "#000000";
OnColorChanged(index, color);
}
private async void OnColorChanged(int index, string newColor)
{
//Console.WriteLine($"Color change at index {index} to {newColor}");
if (index >= 0 && index < TempRanges.Count)
{
TempRanges[index].Color = newColor;
Console.WriteLine($"Updated color: {TempRanges[index].Color}");
await OnRangesChanged.InvokeAsync(TempRanges);
}
else
{
Console.WriteLine($"Invalid index: {index} (Count: {TempRanges.Count})");
}
}
}
}

View File

@ -26,6 +26,14 @@
</svg> <span class="mx-2 mt-0">Articles</span>
</NavLink>
</li>
<li>
<NavLink class="nav-link" href="temperature-blanket">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-border-outer" viewBox="0 0 16 16">
<path d="M7.5 1.906v.938h1v-.938zm0 1.875v.938h1V3.78h-1zm0 1.875v.938h1v-.938zM1.906 8.5h.938v-1h-.938zm1.875 0h.938v-1H3.78v1zm1.875 0h.938v-1h-.938zm2.813 0v-.031H8.5V7.53h-.031V7.5H7.53v.031H7.5v.938h.031V8.5zm.937 0h.938v-1h-.938zm1.875 0h.938v-1h-.938zm1.875 0h.938v-1h-.938zM7.5 9.406v.938h1v-.938zm0 1.875v.938h1v-.938zm0 1.875v.938h1v-.938z" />
<path d="M0 0v16h16V0zm1 1h14v14H1z" />
</svg><span class="mx-2 mt-0">Crochet</span>
</NavLink>
</li>
<li>
<NavLink class="nav-link" href="pokemonsleep">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-p-circle-fill" viewBox="0 0 16 16">

View File

@ -0,0 +1,9 @@
@page "/temperature-blanket"
@attribute [StreamRendering]
@rendermode InteractiveServer
<h3 class="text-xl font-bold mb-4">Crochet</h3>
<TemperatureBlanketVisualizer TemperatureDays="temperatureDays" />

View File

@ -0,0 +1,28 @@
using Microsoft.AspNetCore.Components;
using Portfolio.Domain.Features.TemperatureDay;
using Portfolio.Domain.Features.TemperatureRange;
using Portfolio.WebUI.Server.Components.Component.Crochet_Components;
using static Portfolio.WebUI.Server.Components.Component.Crochet_Components.TemperatureRangeEditor;
namespace Portfolio.WebUI.Server.Components.Pages.Crochet_Pages
{
public partial class CrochetHome
{
public List<TemperatureDay> temperatureDays { get; set; }
protected override async Task OnInitializedAsync()
{
// Placeholder for loading temperature data
// Replace with actual API call
temperatureDays = Enumerable.Range(0, 365).Select(i => new TemperatureDay
{
Date = new DateTime(DateTime.Now.Year - 1, 1, 1).AddDays(i),
AvgTemp = Random.Shared.Next(10, 95)
}).ToList();
}
}
}

View File

@ -0,0 +1,24 @@
@* @page "/import-temperature-data"
@inject NWSWeatherService WeatherService
@inject IWebHostEnvironment Env
<h3>Import Temperature Data</h3>
@if (IsImporting)
{
<p>Importing temperatures... Please wait...</p>
}
else
{
<button class="btn btn-primary" @onclick="ImportAndSaveYearAsync">Import 2024 Data</button>
}
@if (!string.IsNullOrEmpty(Message))
{
<p>@Message</p>
}
*@

View File

@ -0,0 +1,69 @@
using Portfolio.Application.Services.NWSWeatherService;
using Portfolio.Domain.Features.TemperatureDay;
using System.Text.Json;
namespace Portfolio.WebUI.Server.Components.Pages.Crochet_Pages
{
public partial class TemperatureDataImporter
{
private readonly NWSWeatherService _weatherService;
private readonly string _storagePath = "Data/temperature_data_2024.json"; // changeable if needed
public bool IsImporting = true;
public TemperatureDataImporter(NWSWeatherService weatherService)
{
_weatherService = weatherService;
}
public async Task<List<TemperatureDay>> ImportAndSaveYearAsync(int year, double latitude, double longitude)
{
var days = new List<TemperatureDay>();
var stationId = await _weatherService.GetNearestStationAsync(latitude, longitude);
if (stationId == null)
{
throw new Exception("Failed to find nearest station.");
}
var startDate = new DateTime(year, 1, 1);
var endDate = new DateTime(year, 12, 31);
for (var date = startDate; date <= endDate; date = date.AddDays(1))
{
var avgTemp = await _weatherService.GetDailyAverageTempAsync(stationId, date);
if (avgTemp.HasValue)
{
days.Add(new TemperatureDay { Date = date, AvgTemp = avgTemp.Value });
}
await Task.Delay(600); // Respectful API use
}
await SaveToFileAsync(days);
return days;
}
private async Task SaveToFileAsync(List<TemperatureDay> days)
{
Directory.CreateDirectory(Path.GetDirectoryName(_storagePath)!);
var json = JsonSerializer.Serialize(days, new JsonSerializerOptions { WriteIndented = true });
await File.WriteAllTextAsync(_storagePath, json);
Console.WriteLine($"✅ Saved {days.Count} days to {_storagePath}");
}
public async Task<List<TemperatureDay>> LoadSavedDataAsync()
{
if (!File.Exists(_storagePath))
throw new FileNotFoundException($"No saved temperature data found at {_storagePath}");
var json = await File.ReadAllTextAsync(_storagePath);
var days = JsonSerializer.Deserialize<List<TemperatureDay>>(json);
return days ?? new List<TemperatureDay>();
}
}
}

View File

@ -10,11 +10,14 @@
@using Portfolio.WebUI.Server.Components
@using Portfolio.WebUI.Server.Components.Component
@using Portfolio.WebUI.Server.Components.Component.Pokemon_Components
@using Portfolio.WebUI.Server.Components.Component.Crochet_Components
@using Portfolio.Domain.Features.Articles
@using Portfolio.Domain.Features.Pokemon
@using Portfolio.Domain.Features.Pokemon_Natures
@using Portfolio.Domain.Features.Pokemon_Subskills
@using Portfolio.Domain.Features.TemperatureDay
@using Portfolio.Application.Services.Articles
@using Portfolio.Application.Services.PokemonService
@using Portfolio.Application.Services.PokemonNatureService
@using Portfolio.Application.Services.PokemonSubskillService
@using Portfolio.Application.Services.PokemonSubskillService
@using Portfolio.Application.Services.NWSWeatherService