diff --git a/src/App.jsx b/src/App.jsx index f869c95..dece852 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -689,10 +689,10 @@ export default function App(){ // ---- lazy-load ecoregion geojson + L3 yields when a map/AOI tool needs them ---- useEffect(()=>{ - if(!(ecoOn || inspectMode || aoi)) return; - if(!ecoGeo) j("geo/us_eco_l3_features.geojson").then(setEcoGeo).catch(()=>{}); + if(!(ecoOn || inspectMode || aoi || tab==="ecoharvest")) return; + if((ecoOn||inspectMode||aoi||tab==="ecoharvest") && !ecoGeo) j("geo/us_eco_l3_features.geojson").then(setEcoGeo).catch(()=>{}); if(!l3yields) j("api/yield_curves_by_l3.json").then(setL3yields).catch(()=>{}); - },[ecoOn,inspectMode,aoi,ecoGeo,l3yields]); + },[ecoOn,inspectMode,aoi,ecoGeo,l3yields,tab]); // ---- carbon-map year animation (play/pause) ---- useEffect(()=>{ @@ -1430,7 +1430,7 @@ export default function App(){ {(!aoi || researchOpen) && tab==="landis" && } {(!aoi || researchOpen) && tab==="landowner" && } {(!aoi || researchOpen) && tab==="faustmann" && } - {(!aoi || researchOpen) && tab==="ecoharvest" && } + {(!aoi || researchOpen) && tab==="ecoharvest" && } {(!aoi || researchOpen) && (tab==="engines"||tab==="rd") && (<> {LANDIS_STATES.includes(sel) && (
diff --git a/src/EcoregionHarvest.jsx b/src/EcoregionHarvest.jsx index fd894b8..25ef43a 100644 --- a/src/EcoregionHarvest.jsx +++ b/src/EcoregionHarvest.jsx @@ -3,6 +3,7 @@ // the CONUS harvest-probability rasters. A sortable, searchable table — the // "summarize by ecoregion" companion to the per-pixel raster overlays. import { useMemo, useState } from "react"; +import EcoregionMap from "./EcoregionMap.jsx"; const PCOLS = [ ["p_harvest_any","P(any harvest)"], @@ -27,7 +28,14 @@ const shade = v => { return `rgba(${c[0]},${c[1]},${c[2]},0.55)`; }; -export default function EcoregionHarvest({ data }){ +export default function EcoregionHarvest({ data, geo }){ + const MAPFLDS=[["p_harvest_clearcut","P(stand replacement)",v=>v.toFixed(2)], + ["p_harvest_any","P(any harvest)",v=>v.toFixed(2)], + ["stand_height_ft","Stand height (ft)",v=>v.toFixed(0)], + ["cspi_productivity","Productivity (CSPI)",v=>v.toFixed(0)], + ["qmd_in","QMD (in)",v=>v.toFixed(1)]]; + const [mapField,setMapField]=useState("p_harvest_clearcut"); + const mf=MAPFLDS.find(x=>x[0]===mapField)||MAPFLDS[0]; const [sortKey,setSortKey]=useState("p_harvest_any"); const [asc,setAsc]=useState(false); const [q,setQ]=useState(""); @@ -53,6 +61,19 @@ export default function EcoregionHarvest({ data }){ Forest summary by ecoregion {rows.length} EPA Level III ecoregions · ~3.1 km zonal mean · harvest probability + structure + productivity
+ {data && data.ecoregions && geo && ( +
+
+ map: + +
+ +
+ )} setQ(e.target.value)} placeholder="filter by ecoregion or biome…" style={{width:"min(320px,90%)",padding:"4px 8px",marginBottom:6,fontSize:12, background:"var(--panel,#172029)",color:"var(--fg,#e8eef2)",border:"1px solid var(--line,#2a3a47)",borderRadius:5}}/> diff --git a/src/EcoregionMap.jsx b/src/EcoregionMap.jsx new file mode 100644 index 0000000..c95e71b --- /dev/null +++ b/src/EcoregionMap.jsx @@ -0,0 +1,51 @@ +// Compact CONUS choropleth of an ecoregion-summary field (EPA L3 polygons). +// Self-contained Albers projection (mirrors SVGMap/PermanenceMap) so the +// ecoregion tab can show its summary spatially without the full map stack. +const W=640,H=400,PAD=8; +const PHI0=37.5*Math.PI/180,PHI1=29.5*Math.PI/180,PHI2=45.5*Math.PI/180,LAM0=-96*Math.PI/180; +const N=(Math.sin(PHI1)+Math.sin(PHI2))/2, C=Math.cos(PHI1)**2+2*N*Math.sin(PHI1), RHO0=Math.sqrt(C-2*N*Math.sin(PHI0))/N; +function project(lon,lat){ const phi=lat*Math.PI/180,lam=lon*Math.PI/180; + const rho=Math.sqrt(Math.max(0,C-2*N*Math.sin(phi)))/N, theta=N*(lam-LAM0); + return [rho*Math.sin(theta), RHO0-rho*Math.cos(theta)]; } +const _c=[[-125,50],[-66,50],[-125,24],[-66,24],[-95,49],[-95,25]].map(p=>project(...p)); +const _xs=_c.map(c=>c[0]),_ys=_c.map(c=>c[1]); +const _x0=Math.min(..._xs),_x1=Math.max(..._xs),_y0=Math.min(..._ys),_y1=Math.max(..._ys),_dx=_x1-_x0,_dy=_y1-_y0; +const SCALE=Math.min((W-2*PAD)/_dx,(H-2*PAD)/_dy), TX=-_x0*SCALE+(W-_dx*SCALE)/2, TY=PAD+SCALE*_y1+(H-2*PAD-_dy*SCALE)/2; +const projPath=(lon,lat)=>{ const [x,y]=project(lon,lat); return [x*SCALE+TX,-y*SCALE+TY]; }; +const ringToD=r=>{ let d=""; for(let i=0;i{ if(!g) return ""; const polys=g.type==="Polygon"?[g.coordinates]:g.coordinates; return polys.map(p=>p.map(ringToD).join(" ")).join(" "); }; +// sequential blue->amber->red, value normalized 0..1 +const STOPS=[[0,[47,98,158]],[0.5,[202,161,90]],[1,[224,90,90]]]; +const ramp=t=>{ if(t==null||isNaN(t)) return "#2a3a47"; t=Math.max(0,Math.min(1,t)); + let a=STOPS[0],b=STOPS[STOPS.length-1]; + for(let i=1;iMath.round(ca+f*(b[1][k]-ca))); + return "#"+c.map(x=>x.toString(16).padStart(2,"0")).join(""); }; + +export default function EcoregionMap({ geo, eco, field, label, fmt }){ + if(!geo || !geo.features || !eco) return
Loading ecoregion map…
; + const feats=geo.features.filter(ft=>ft.properties && ft.properties.NA_L3CODE); + const vals=Object.values(eco).map(v=>v[field]).filter(v=>v!=null&&isFinite(v)); + const lo=Math.min(...vals), hi=Math.max(...vals), span=(hi-lo)||1; + return ( +
+ + + {feats.map((ft,i)=>{ + const code=ft.properties.NA_L3CODE; const r=eco[code]; const v=r?r[field]:null; + const t=(v!=null&&isFinite(v))?(v-lo)/span:null; + return + {`${r?r.name:code}${v!=null?` · ${label}: ${fmt?fmt(v):v}`:" · no data"}`} + ; + })} + + {[0,0.25,0.5,0.75,1].map((t,i)=>())} + {fmt?fmt(lo):lo.toFixed(1)} + {fmt?fmt(hi):hi.toFixed(1)} + + {label} by ecoregion + +
+ ); +}