load scores every 5 minutes and display a colored map

This commit is contained in:
2025-11-19 17:32:32 -08:00
parent b4487ffd72
commit e805b2ea53
7 changed files with 135 additions and 489 deletions

5
.gitignore vendored
View File

@ -1,6 +1,3 @@
/target
*~
player_scores.csv
team_scores.csv
territory_breakdown.csv
players.csv
scores.json

View File

@ -12,13 +12,7 @@
<div id="map"></div>
<script>
function style(feature) {
// Customize color based on a feature property; e.g., 'type' or 'category'
switch(feature.properties.type) {
case 'park': return { color: '#228B22' }; // green
case 'water': return { color: '#1E90FF' }; // blue
case 'residential': return { color: '#FFD700' }; // gold
default: return { color: '#FF0000' }; // red fallback
}
return { color: feature.properties.color};
}
var map = L.map('map').setView([47.6062, -122.3321], 12);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {

View File

@ -1,322 +0,0 @@
use std::{collections::{HashMap, HashSet}, error::Error};
use csv::{Reader, Writer};
use crate::data::{Player, PlayerScore, TeamScore};
/// Load players from a CSV file
/// Expected format: username,team
pub fn load_players(file_path: &str) -> Result<Vec<Player>, Box<dyn Error>> {
let mut reader = Reader::from_path(file_path)?;
let mut players = Vec::new();
for result in reader.deserialize() {
let player: Player = result?;
players.push(player);
}
Ok(players)
}
/// Write player scores to a CSV file
pub fn write_player_scores(
file_path: &str,
player_scores: &[PlayerScore],
) -> Result<(), Box<dyn Error>> {
let mut writer = Writer::from_path(file_path)?;
// Write header
writer.write_record(&["username", "team", "total_score"])?;
// Write data
for score in player_scores {
writer.write_record(&[
&score.player.username,
&score.player.team,
&score.total_score.to_string(),
])?;
}
writer.flush()?;
Ok(())
}
/// Write team scores to a CSV file
pub fn write_team_scores(
file_path: &str,
team_scores: &[TeamScore],
) -> Result<(), Box<dyn Error>> {
let mut writer = Writer::from_path(file_path)?;
let mut territories: HashSet<String> = HashSet::new();
for score in team_scores.iter() {
for (territory, _) in score.territory_scores.clone() {
territories.insert(territory);
}
}
let territories: Vec<String> = territories.into_iter().collect();
// Write header
let mut header = vec![String::from("team")];
header.append(&mut territories.clone());
writer.write_record(&header)?;
// Write data
for score in team_scores {
let mut scores = vec![score.team.clone()];
let default = 0;
for territory in territories.clone() {
scores.push(score.territory_scores.get(&territory).unwrap_or(&default).to_string());
}
writer.write_record(scores)?;
}
writer.flush()?;
Ok(())
}
/// Write detailed territory scores to a CSV file
pub fn write_territory_scores(
file_path: &str,
player_scores: &[PlayerScore],
) -> Result<(), Box<dyn Error>> {
let mut writer = Writer::from_path(file_path)?;
// Collect all unique territories
let mut territories: Vec<String> = player_scores
.iter()
.flat_map(|ps| ps.territory_scores.keys())
.cloned()
.collect::<std::collections::HashSet<_>>()
.into_iter()
.collect();
territories.sort();
// Write header
let mut header = vec!["username".to_string(), "team".to_string()];
header.extend(territories.iter().cloned());
header.push("total".to_string());
writer.write_record(&header)?;
// Write data
for score in player_scores {
let mut row = vec![score.player.username.clone(), score.player.team.clone()];
for territory in &territories {
let territory_score = score.territory_scores.get(territory).unwrap_or(&0);
row.push(territory_score.to_string());
}
row.push(score.total_score.to_string());
writer.write_record(&row)?;
}
writer.flush()?;
Ok(())
}
/// Calculate team scores from player scores
pub fn calculate_team_scores(player_scores: &[PlayerScore]) -> Vec<TeamScore> {
let mut team_map: HashMap<String, TeamScore> = HashMap::new();
for player_score in player_scores {
let team_score = team_map
.entry(player_score.player.team.clone())
.or_insert_with(|| TeamScore::new(player_score.player.team.clone()));
for (territory, score) in &player_score.territory_scores {
team_score.add_territory_score(territory.clone(), *score);
}
}
let mut teams: Vec<TeamScore> = team_map.into_values().collect();
teams.sort_by(|a, b| b.total_score.cmp(&a.total_score));
teams
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::io::Write;
use tempfile::NamedTempFile;
#[test]
fn test_load_players() {
let mut temp_file = NamedTempFile::new().unwrap();
writeln!(temp_file, "username,team").unwrap();
writeln!(temp_file, "player1,Red Team").unwrap();
writeln!(temp_file, "player2,Blue Team").unwrap();
writeln!(temp_file, "player3,Red Team").unwrap();
temp_file.flush().unwrap();
let players = load_players(temp_file.path().to_str().unwrap()).unwrap();
assert_eq!(players.len(), 3);
assert_eq!(players[0].username, "player1");
assert_eq!(players[0].team, "Red Team");
assert_eq!(players[1].username, "player2");
assert_eq!(players[1].team, "Blue Team");
}
#[test]
fn test_write_player_scores() {
let temp_file = NamedTempFile::new().unwrap();
let file_path = temp_file.path().to_str().unwrap();
let player1 = Player {
username: "player1".to_string(),
team: "Red Team".to_string(),
};
let mut score1 = PlayerScore::new(player1);
score1.add_territory_score("Capitol Hill".to_string(), 10);
let player2 = Player {
username: "player2".to_string(),
team: "Blue Team".to_string(),
};
let mut score2 = PlayerScore::new(player2);
score2.add_territory_score("Downtown".to_string(), 5);
let player_scores = vec![score1, score2];
write_player_scores(file_path, &player_scores).unwrap();
let content = fs::read_to_string(file_path).unwrap();
assert!(content.contains("username,team,total_score"));
assert!(content.contains("player1,Red Team,10"));
assert!(content.contains("player2,Blue Team,5"));
}
#[test]
fn test_write_team_scores() {
let temp_file = NamedTempFile::new().unwrap();
let file_path = temp_file.path().to_str().unwrap();
let mut team1 = TeamScore::new("Red Team".to_string());
team1.add_territory_score("Capitol Hill".to_string(), 20);
let mut team2 = TeamScore::new("Blue Team".to_string());
team2.add_territory_score("Downtown".to_string(), 15);
let team_scores = vec![team1, team2];
write_team_scores(file_path, &team_scores).unwrap();
let content = fs::read_to_string(file_path).unwrap();
assert!(content.contains("team,Capitol Hill,Downtown"));
assert!(content.contains("Red Team,20,0"));
assert!(content.contains("Blue Team,0,15"));
}
#[test]
fn test_calculate_team_scores() {
let player1 = Player {
username: "player1".to_string(),
team: "Red Team".to_string(),
};
let mut score1 = PlayerScore::new(player1);
score1.add_territory_score("Capitol Hill".to_string(), 10);
score1.add_territory_score("Downtown".to_string(), 5);
let player2 = Player {
username: "player2".to_string(),
team: "Red Team".to_string(),
};
let mut score2 = PlayerScore::new(player2);
score2.add_territory_score("Capitol Hill".to_string(), 8);
let player3 = Player {
username: "player3".to_string(),
team: "Blue Team".to_string(),
};
let mut score3 = PlayerScore::new(player3);
score3.add_territory_score("Fremont".to_string(), 12);
let player_scores = vec![score1, score2, score3];
let team_scores = calculate_team_scores(&player_scores);
assert_eq!(team_scores.len(), 2);
// Red Team should be first (higher score)
assert_eq!(team_scores[0].team, "Red Team");
assert_eq!(team_scores[0].total_score, 23); // 10 + 5 + 8
assert_eq!(team_scores[0].territory_scores.get("Capitol Hill"), Some(&18));
assert_eq!(team_scores[0].territory_scores.get("Downtown"), Some(&5));
// Blue Team should be second
assert_eq!(team_scores[1].team, "Blue Team");
assert_eq!(team_scores[1].total_score, 12);
assert_eq!(team_scores[1].territory_scores.get("Fremont"), Some(&12));
}
#[test]
fn test_write_territory_scores() {
let temp_file = NamedTempFile::new().unwrap();
let file_path = temp_file.path().to_str().unwrap();
let player1 = Player {
username: "player1".to_string(),
team: "Red Team".to_string(),
};
let mut score1 = PlayerScore::new(player1);
score1.add_territory_score("Capitol Hill".to_string(), 10);
score1.add_territory_score("Downtown".to_string(), 5);
let player2 = Player {
username: "player2".to_string(),
team: "Blue Team".to_string(),
};
let mut score2 = PlayerScore::new(player2);
score2.add_territory_score("Capitol Hill".to_string(), 8);
let player_scores = vec![score1, score2];
write_territory_scores(file_path, &player_scores).unwrap();
let content = fs::read_to_string(file_path).unwrap();
// Check header contains territory names
assert!(content.contains("username"));
assert!(content.contains("team"));
assert!(content.contains("Capitol Hill"));
assert!(content.contains("Downtown"));
assert!(content.contains("total"));
// Check player data
assert!(content.contains("player1"));
assert!(content.contains("player2"));
}
#[test]
fn test_calculate_team_scores_sorting() {
let player1 = Player {
username: "player1".to_string(),
team: "Team A".to_string(),
};
let mut score1 = PlayerScore::new(player1);
score1.add_territory_score("Area1".to_string(), 5);
let player2 = Player {
username: "player2".to_string(),
team: "Team B".to_string(),
};
let mut score2 = PlayerScore::new(player2);
score2.add_territory_score("Area2".to_string(), 20);
let player3 = Player {
username: "player3".to_string(),
team: "Team C".to_string(),
};
let mut score3 = PlayerScore::new(player3);
score3.add_territory_score("Area3".to_string(), 15);
let player_scores = vec![score1, score2, score3];
let team_scores = calculate_team_scores(&player_scores);
// Should be sorted by total_score descending
assert_eq!(team_scores[0].team, "Team B");
assert_eq!(team_scores[0].total_score, 20);
assert_eq!(team_scores[1].team, "Team C");
assert_eq!(team_scores[1].total_score, 15);
assert_eq!(team_scores[2].team, "Team A");
assert_eq!(team_scores[2].total_score, 5);
}
}

View File

@ -4,57 +4,25 @@ use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Player {
pub username: String,
pub team: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct PlayerScore {
pub player: Player,
pub total_score: i32,
pub territory_scores: HashMap<String, i32>,
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Team {
pub name: String,
pub color: String,
pub players: Vec<Player>,
pub scores: HashMap<String, i32>,
}
impl PlayerScore {
pub fn new(player: Player) -> Self {
PlayerScore {
player: player,
total_score: 0,
territory_scores: HashMap::new(),
impl Team {
pub fn add_scores(&mut self, scores: &HashMap<String, i32>) {
for (territory, score) in scores {
self.scores
.entry(territory.to_string())
.and_modify(|s| *s += score)
.or_insert(*score);
}
}
pub fn add_territory_score(&mut self, territory: String, score: i32) {
self.total_score += score;
self.territory_scores
.entry(territory)
.and_modify(|s| *s += score)
.or_insert(score);
}
}
#[derive(Debug, Clone, Serialize)]
pub struct TeamScore {
pub team: String,
pub total_score: i32,
pub territory_scores: HashMap<String, i32>,
}
impl TeamScore {
pub fn new(team: String) -> Self {
TeamScore {
team,
total_score: 0,
territory_scores: HashMap::new(),
}
}
pub fn add_territory_score(&mut self, territory: String, score: i32) {
self.total_score += score;
self.territory_scores
.entry(territory)
.and_modify(|s| *s += score)
.or_insert(score);
}
}
#[cfg(test)]

View File

@ -4,15 +4,18 @@ use std::convert::TryFrom;
use std::fs;
use geo::algorithm::contains::Contains;
use geojson::{FeatureCollection, GeoJson, Geometry, Value};
use crate::Team;
pub struct GeoUtils {
feature_collection: FeatureCollection
}
impl GeoUtils {
pub fn new() -> Self {
let fc = Self::load_feature_collection();
let mut fc = Self::load_feature_collection();
for feature in &mut fc.features {
feature.set_property("color", "#888888ff");
}
Self {feature_collection: fc}
}
@ -26,47 +29,70 @@ impl GeoUtils {
pub fn get_feature_collection_copy(self) -> FeatureCollection {
self.feature_collection.clone()
}
}
pub fn get_territory_for(point: geo_types::Point) -> String {
let txt: String = fs::read_to_string("Neighborhood_Map_Atlas_Neighborhoods.geojson").expect("file should be present");
let geojson_str = txt;
let geojson: GeoJson = geojson_str.parse::<GeoJson>().unwrap();
let feature_collection: FeatureCollection = FeatureCollection::try_from(geojson).unwrap();
for feature in feature_collection {
let name = feature.property("S_HOOD").unwrap_or_default();
let name = name.as_str().unwrap_or("unknown");
let geometry: Geometry = feature.clone().geometry.expect("Feature has no geometry");
match geometry.value {
Value::Polygon(coords) => {
// Outer ring
let exterior = coords[0]
.iter()
.map(|coord| (coord[0], coord[1]))
.collect::<Vec<(f64, f64)>>();
let polygon = geo_types::Polygon::new(exterior.into(), vec![]);
if polygon.contains(&point) {
return String::from(name);
pub fn get_colored_collection_copy(self, teams: Vec<Team>) -> FeatureCollection {
let mut copy = self.feature_collection.clone();
for feature in &mut copy.features {
let name = feature.property("S_HOOD").unwrap_or_default();
let name = name.as_str().unwrap_or("unknown");
let mut max_team: (Option<String>, i32) = (None, 0);
for team in &teams {
let score = team.scores.get(name).unwrap_or(&0);
if *score > max_team.1 {
max_team = (Some(team.color.clone()), *score);
} else if *score > 0 && *score == max_team.1 {
// Tie doesn't count
max_team = (None, 0);
}
}
Value::MultiPolygon(coords) => {
let multipoly = coords.iter().map(|poly_coords| {
let exterior = poly_coords[0]
match max_team.0 {
None => feature.set_property("color", "#888888ff"),
Some(c) => {
feature.set_property("color", c);
}
}
}
copy
}
pub fn get_territory_for(self, point: geo_types::Point) -> String {
let feature_collection: FeatureCollection = self.get_feature_collection_copy();
for feature in feature_collection {
let name = feature.property("S_HOOD").unwrap_or_default();
let name = name.as_str().unwrap_or("unknown");
let geometry: Geometry = feature.clone().geometry.expect("Feature has no geometry");
match geometry.value {
Value::Polygon(coords) => {
// Outer ring
let exterior = coords[0]
.iter()
.map(|coord| (coord[0], coord[1]))
.collect::<Vec<(f64, f64)>>()
.try_into()
.expect("Invalid exterior ring");
geo_types::Polygon::new(exterior, vec![])
}).collect::<Vec<geo_types::Polygon<f64>>>();
let multipolygon = geo_types::MultiPolygon::new(multipoly);
if multipolygon.contains(&point) {
return String::from(name);
.collect::<Vec<(f64, f64)>>();
let polygon = geo_types::Polygon::new(exterior.into(), vec![]);
if polygon.contains(&point) {
return String::from(name);
}
}
}
_ => panic!("Geometry is not supported"),
};
Value::MultiPolygon(coords) => {
let multipoly = coords.iter().map(|poly_coords| {
let exterior = poly_coords[0]
.iter()
.map(|coord| (coord[0], coord[1]))
.collect::<Vec<(f64, f64)>>()
.try_into()
.expect("Invalid exterior ring");
geo_types::Polygon::new(exterior, vec![])
}).collect::<Vec<geo_types::Polygon<f64>>>();
let multipolygon = geo_types::MultiPolygon::new(multipoly);
if multipolygon.contains(&point) {
return String::from(name);
}
}
_ => panic!("Geometry is not supported"),
};
}
String::from("Outside")
}
String::from("Outside")
}
}

View File

@ -1,6 +1,5 @@
pub mod osm;
mod data;
mod csv_io;
mod geo_utils;
use crate::osm::scores;
@ -9,14 +8,23 @@ use std::fs;
use chrono::NaiveDateTime;
use reqwest::Client;
use xml::reader::EventReader;
use actix_web::{get, post, web, App, HttpResponse, HttpServer, Responder};
use actix_web::{get, App, HttpResponse, HttpServer, Responder};
use crate::data::{Team, Player};
use std::collections::HashMap;
use std::fs::File;
use std::io::BufWriter;
use std::io::Write;
use tokio::time;
use std::time::Duration;
const DATE_FORMAT: &str = "%Y-%m-%dT%H:%M:%SZ";
#[get("/geojson")]
async fn geojson_endpoint() -> impl Responder {
let utils = geo_utils::GeoUtils::new();
let feature_collection = utils.get_feature_collection_copy();
let teams = read_scores();
let feature_collection = utils.get_colored_collection_copy(teams);
HttpResponse::Ok()
.content_type("application/json")
.body(serde_json::to_string(&feature_collection).unwrap())
@ -32,6 +40,13 @@ async fn index() -> impl Responder {
#[actix_web::main]
async fn main() -> std::io::Result<()> {
let mut interval = time::interval(Duration::from_secs(60 * 5));
let _maintainance = tokio::task::spawn(async move {
loop {
interval.tick().await;
let _ = update_scores().await;
}
});
HttpServer::new(|| {
App::new()
.service(index)
@ -42,53 +57,50 @@ async fn main() -> std::io::Result<()> {
.await
}
//#[tokio::main]
async fn main2()-> Result<(), Box<dyn Error>> {
async fn update_scores()-> Result<(), Box<dyn Error>> {
let client = Client::builder()
.user_agent("MapBattle/0.1")
.build()?;
// Load players from CSV
let players = csv_io::load_players("players.csv")?;
println!("Loaded {} players", players.len());
// Load players from a database?
let mut teams: Vec<Team> = vec![
Team{
name: String::from("potato"),
color: String::from("#df1aeaff"),
scores: HashMap::new(),
players: vec![
Player {
username: String::from("ammaratef45")
}
]
}
];
// Configure time range
let start_date = NaiveDateTime::parse_from_str("2025-11-13T11:55:07Z", DATE_FORMAT)?;
let end_date = NaiveDateTime::parse_from_str("2025-11-13T15:55:07Z", DATE_FORMAT)?;
// Calculate scores for each player
let mut player_scores = Vec::new();
for player in players {
println!("Processing player: {} ({})", player.username, player.team);
let body = scores::get_changesets_for_user(player.username.clone(), &client).await?;
let reader = EventReader::from_str(&body);
let changesets = scores::extract_changesets_from_reader(reader);
let changesets = scores::time_bound_changesets(changesets, start_date, end_date);
let territory_scores = scores::changesets_to_points(changesets, &client).await;
let mut player_score = data::PlayerScore::new(player);
for (territory, score) in territory_scores {
player_score.add_territory_score(territory, score);
for team in &mut teams {
for player in team.players.clone() {
println!("Processing player: {} ({})", player.username, team.name);
let body = scores::get_changesets_for_user(player.username.clone(), &client).await?;
let reader = EventReader::from_str(&body);
let changesets = scores::extract_changesets_from_reader(reader);
let changesets = scores::time_bound_changesets(changesets, start_date, end_date);
let territory_scores = scores::changesets_to_points(changesets).await;
team.add_scores(&territory_scores);
}
println!(" Total score: {}", player_score.total_score);
player_scores.push(player_score);
}
// Calculate team scores
let team_scores = csv_io::calculate_team_scores(&player_scores);
// Write results to CSV files
csv_io::write_player_scores("player_scores.csv", &player_scores)?;
csv_io::write_team_scores("team_scores.csv", &team_scores)?;
csv_io::write_territory_scores("territory_breakdown.csv", &player_scores)?;
println!("\nResults written to:");
println!(" - player_scores.csv");
println!(" - team_scores.csv");
println!(" - territory_breakdown.csv");
let file = File::create("scores.json")?;
let mut writer = BufWriter::new(file);
serde_json::to_writer_pretty(&mut writer, &teams)?;
writer.flush()?;
Ok(())
}
fn read_scores() -> Vec<Team> {
let text: String = fs::read_to_string("scores.json").expect("file should be present");
serde_json::from_str(&text).unwrap()
}

View File

@ -12,34 +12,6 @@ mod network {
}
}
mod xml_utils {
use xml::reader::{EventReader, XmlEvent};
pub fn extract_tag_value_from_reader(reader: EventReader<&[u8]>, tag: String) -> Option<String> {
let mut inside_tag = false;
for e in reader {
match e {
Ok(XmlEvent::StartElement { name, .. }) => {
if name.local_name == tag {
inside_tag = true;
}
}
Ok(XmlEvent::Characters(data)) => {
if inside_tag {
return Some(data);
}
}
Ok(XmlEvent::EndElement { name }) => {
if name.local_name == tag {
inside_tag = false;
}
}
_ => {}
}
}
None
}
}
pub mod scores {
use crate::osm::network;
use chrono::NaiveDateTime;
@ -67,7 +39,7 @@ pub mod scores {
let reader = EventReader::from_str(&raw_changesets);
let changesets = extract_changesets_from_reader(reader);
let changesets = time_bound(changesets, input.start_time, input.end_time);
let points = to_points(changesets, &client).await;
let points = to_points(changesets).await;
println!("Points for {player}: {points:?}");
}
}
@ -116,7 +88,7 @@ pub mod scores {
}
// Public function for converting changesets to points
pub async fn changesets_to_points(changesets: Vec<Vec<OwnedAttribute>>, client: &Client) -> HashMap<String, i32> {
pub async fn changesets_to_points(changesets: Vec<Vec<OwnedAttribute>>) -> HashMap<String, i32> {
let mut result: HashMap<String, i32> = HashMap::new();
for changeset in changesets {
let min_lat = changeset.clone().into_iter().find(|att| att.name.local_name.eq("min_lat")).unwrap().value;
@ -127,8 +99,7 @@ pub mod scores {
let score = changeset.into_iter().find(|att| att.name.local_name.eq("changes_count")).unwrap().value;
let score:i32 = score.parse().expect("changes count returned as not a string");
let point = geo_types::Point::new(min_lon, min_lat);
let territory = geo_utils::get_territory_for(point);
println!("{territory}");
let territory = geo_utils::GeoUtils::new().get_territory_for(point);
result.entry(territory).and_modify(|val| *val += score).or_insert(score);
}
result
@ -138,8 +109,8 @@ pub mod scores {
time_bound_changesets(input, start_date, end_date)
}
async fn to_points(changesets: Vec<Vec<OwnedAttribute>>, client: &Client) -> HashMap<String, i32> {
changesets_to_points(changesets, client).await
async fn to_points(changesets: Vec<Vec<OwnedAttribute>>) -> HashMap<String, i32> {
changesets_to_points(changesets).await
}
}