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
10 changes: 10 additions & 0 deletions .github/workflows/docker-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,13 @@ jobs:
tags: |
pcic/chyp-server:${{ github.ref_name }}
${{ github.ref_name == 'main' && 'pcic/chyp-server:latest' || '' }}

- name: Build and Publish Varnish
uses: docker/build-push-action@v4
with:
context: .
file: docker/varnish/Dockerfile
push: true
tags: |
pcic/chyp-varnish:${{ github.ref_name }}
${{ github.ref_name == 'main' && 'pcic/chyp-varnish:latest' || '' }}
11 changes: 9 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ Commands should be run from within the `app` folder.
- Vector tiles used to display over 100k lakes and river segments across British Columbia
- Lakes and rivers shown in blue, highlighted in red when selected.
- Hover to dynamically highlight features.
- Selecting a feature open the **Data Download Panel** and provides a link to download the GeoJSON for the feature.
- Selecting a feature opens the **Data Download Panel** and provides links to download GeoJSON for the selected feature or its complete upstream/downstream network.

### BC Freshwater Atlas Lake & River Search

Expand Down Expand Up @@ -80,6 +80,9 @@ A BBox instance running at the same host is assumed by InteractionLayer.jsx and

The project includes GitHub Actions workflows for Docker image publishing. Docker images for the app, data import, and BBOX server are automatically built and published upon branch pushes and tagged releases.

Docker Swarm deployments must use the repository's explicit readiness gates;
See [Swarm startup readiness](docs/swarm-readiness.md) for the required stack configuration and rollout procedure.

## API Integration

The app connects to the PCIC Hydromosaic API for:
Expand All @@ -95,7 +98,10 @@ BC Geographic Warehouse (BCGW) [Public Map Server](https://delivery.maps.gov.bc.

## Environment Variables

- `REACT_APP_BC_BASE_MAP_TILES_URL`: Base map tile URL (defaults to PCIC swarm server)
- `REACT_APP_BC_BASE_MAP_TILES_URL`: Base map tile URL (defaults to the
production PNG tiles on `services.pacificclimate.org`). The app container
reads this variable when it starts, so non-production deployments can use a
different tile server and format without rebuilding the image.

## Docker Images

Expand All @@ -105,6 +111,7 @@ The following images are published to Docker Hub:
- `pcic/chyp-data-import`: Data import utilities
- `pcic/chyp-postgis`: PostGIS database
- `pcic/chyp-server`: BBOX tile server
- `pcic/chyp-varnish`: BBOX tile cache with an upstream readiness check

## Requirements

Expand Down
7 changes: 5 additions & 2 deletions app/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,14 @@

<head>
<meta charset="UTF-8" />
<link rel="preconnect" href="https://swarm.pacificclimate.org" crossorigin />
<script>window.__CHYP_CONFIG__ = {};</script>
<link rel="dns-prefetch" href="//delivery.maps.gov.bc.ca" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<link rel="preload" as="image" href="https://swarm.pacificclimate.org/tiles/bc-albers-lite/6/33/29.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
name="description"
content="Explore channel-scale hydrologic model output for rivers and lakes across British Columbia."
/>
<title>Channel-Scale Hydrologic Model Output Portal</title>
</head>

Expand Down
6 changes: 5 additions & 1 deletion app/src/App.jsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import MapComponent from "./components/map/MapComponent.jsx";

const App = () => <MapComponent />;
const App = () => (
<main>
<MapComponent />
</main>
);

export default App;
53 changes: 53 additions & 0 deletions app/src/baseMapConfig.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { BCBaseMap } from "pcic-react-leaflet-components";

const runtimeTileUrl =
globalThis.__CHYP_CONFIG__?.REACT_APP_BC_BASE_MAP_TILES_URL?.trim();

export const baseMapTileUrl = runtimeTileUrl || BCBaseMap.tileset.url;

const upsertResourceHint = ({ id, rel, href, as, fetchPriority }) => {
const link = document.getElementById(id) || document.createElement("link");

link.id = id;
link.rel = rel;
link.href = href;
if (as) {
link.as = as;
}
if (fetchPriority) {
link.fetchPriority = fetchPriority;
}

if (!link.isConnected) {
document.head.appendChild(link);
}
};

export const configureBaseMapResourceHints = () => {
if (!baseMapTileUrl) {
return;
}

try {
upsertResourceHint({
id: "base-map-preconnect",
rel: "preconnect",
href: new URL(baseMapTileUrl).origin,
});
} catch {
// Leaflet will report an invalid tile URL when it tries to use it.
}

upsertResourceHint({
id: "base-map-preload",
rel: "preload",
as: "image",
fetchPriority: "high",
href: baseMapTileUrl
.replace("{z}", "6")
.replace("{x}", "33")
.replace("{y}", "29"),
});
};

export { BCBaseMap };
26 changes: 25 additions & 1 deletion app/src/components/data/DataSelection.css
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@
border-radius: 12px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
z-index: 1000;
width: 380px;
max-width: calc(100vw - 32px);
min-width: 300px;
box-sizing: border-box;
font-family: Arial, sans-serif;
}

Expand All @@ -18,6 +21,9 @@
}

.data-selection select {
width: 100%;
min-width: 0;
box-sizing: border-box;
padding: 8px 12px;
border: 1px solid #ccc;
border-radius: 8px;
Expand All @@ -44,6 +50,24 @@
background-color: #0056b3;
}

.data-selection button:disabled {
background-color: #7b8a99;
cursor: not-allowed;
}

.data-selection .network-downloads {
display: flex;
flex-direction: column;
gap: 8px;
padding-top: 4px;
border-top: 1px solid #bbb;
}

.data-selection .network-download-note {
color: #444;
text-align: center;
}

.data-selection .header {
display: flex;
justify-content: space-between;
Expand Down Expand Up @@ -95,4 +119,4 @@

.data-selection.shake {
animation: shake 0.4s ease-in-out;
}
}
115 changes: 106 additions & 9 deletions app/src/components/data/DataSelectionTable.jsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { useState, useEffect, useCallback } from "react";
import PropTypes from "prop-types";
import {
fetchBulkTimeseries,
getAvailableOptions,
downloadTimeseries,
getTimeseriesDownload,
} from "../../services/timeseriesApi.js";
import { downloadBlob, downloadUrl } from "../../utils/downloadFile.js";
import "./DataSelection.css";

const HISTORICAL_SCENARIO = "historical";
Expand All @@ -15,7 +17,22 @@ const getModelLabel = (model) =>
const getScenarioLabel = (scenario) =>
scenario === HISTORICAL_SCENARIO ? `${scenario} (PNWNAmet only)` : scenario;

const DataSelectionTable = ({ featureId, onClose }) => {
const getVariableLabel = (variable) =>
variable.startsWith(
"mass concentration of maximum amount of oxygen that will dissolve"
)
? variable.replace(
/^mass concentration of maximum amount of oxygen that will dissolve in water at given temperature and pressure/,
"Dissolved oxygen saturation"
)
: variable;

const DataSelectionTable = ({
featureId,
upstreamSubids,
downstreamSubids,
onClose,
}) => {
const outletId = `${featureId}`;
const [options, setOptions] = useState({
models: [],
Expand All @@ -28,7 +45,7 @@ const DataSelectionTable = ({ featureId, onClose }) => {
variable: "",
});
const [isFetching, setIsFetching] = useState(true);
const [isLoading, setIsLoading] = useState(false);
const [activeDownload, setActiveDownload] = useState(null);
const [showValidation, setShowValidation] = useState(false);
const [shake, setShake] = useState(false);

Expand Down Expand Up @@ -75,17 +92,53 @@ const DataSelectionTable = ({ featureId, onClose }) => {
return;
}

setIsLoading(true);
setActiveDownload("selected");
try {
await downloadTimeseries(outletId, selections);
const { url, filename } = await getTimeseriesDownload(
outletId,
selections
);
downloadUrl(url, filename);
} catch (error) {
console.error("Download error:", error);
alert("Failed to download data");
} finally {
setIsLoading(false);
setActiveDownload(null);
}
}, [outletId, selections]);

const handleBulkDownload = useCallback(
async (direction) => {
if (Object.values(selections).some((v) => !v)) {
setShowValidation(true);
setShake(true);
setTimeout(() => setShake(false), 650);
return;
}

const subids =
direction === "upstream" ? upstreamSubids : downstreamSubids;
if (!subids || subids.length <= 1) return;

setActiveDownload(direction);
try {
const { blob, filename } = await fetchBulkTimeseries(
outletId,
direction,
subids,
selections
);
downloadBlob(blob, filename);
} catch (error) {
console.error("Bulk download error:", error);
alert(`Failed to download ${direction} data`);
} finally {
setActiveDownload(null);
}
},
[downstreamSubids, outletId, selections, upstreamSubids]
);

return (
<div className={`data-selection ${shake ? "shake" : ""}`}>
<form onSubmit={(e) => e.preventDefault()}>
Expand Down Expand Up @@ -147,21 +200,65 @@ const DataSelectionTable = ({ featureId, onClose }) => {
</option>
{options.variables.map((variable) => (
<option key={variable} value={variable}>
{variable}
{getVariableLabel(variable)}
</option>
))}
</select>

<button type="button" onClick={handleDownload} disabled={isLoading}>
{isLoading ? "Downloading..." : "Download CSV"}
<button
type="button"
onClick={handleDownload}
disabled={activeDownload !== null}
>
{activeDownload === "selected"
? "Downloading..."
: "Download selected CSV"}
</button>

<div className="network-downloads">
<button
type="button"
onClick={() => handleBulkDownload("upstream")}
disabled={
activeDownload !== null || (upstreamSubids?.length ?? 0) <= 1
}
>
{activeDownload === "upstream"
? "Downloading upstream..."
: upstreamSubids == null
? "Loading upstream network..."
: upstreamSubids.length <= 1
? "No upstream outlets"
: `Download upstream NetCDF (${upstreamSubids.length})`}
</button>
<button
type="button"
onClick={() => handleBulkDownload("downstream")}
disabled={
activeDownload !== null || (downstreamSubids?.length ?? 0) <= 1
}
>
{activeDownload === "downstream"
? "Downloading downstream..."
: downstreamSubids == null
? "Loading downstream network..."
: downstreamSubids.length <= 1
? "No downstream outlets"
: `Download downstream NetCDF (${downstreamSubids.length})`}
</button>
</div>
<small className="network-download-note">
Network downloads include the selected segment.
</small>
</form>
</div>
);
};

DataSelectionTable.propTypes = {
featureId: PropTypes.string.isRequired,
upstreamSubids: PropTypes.arrayOf(PropTypes.string),
downstreamSubids: PropTypes.arrayOf(PropTypes.string),
onClose: PropTypes.func.isRequired,
};

Expand Down
9 changes: 6 additions & 3 deletions app/src/components/info/HelpGuide.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -107,8 +107,8 @@ const HelpGuide = () => {
</ul>
</li>
<li>Enter X/Y or Longitude/Latitude coordinates</li>
<li>Click "Plot Point" to add marker</li>
<li>Use "Clear Marker" to remove point</li>
<li>Click &quot;Plot Point&quot; to add marker</li>
<li>Use &quot;Clear Marker&quot; to remove point</li>
</ul>
</section>

Expand All @@ -129,7 +129,10 @@ const HelpGuide = () => {
<li>
Choose model, scenario, and variable from dropdown menus
</li>
<li>Click "Download CSV" to get timeseries data</li>
<li>
Download the selected segment as CSV, or its complete
upstream/downstream network as a multi-outlet NetCDF
</li>
</ol>
</section>

Expand Down
2 changes: 2 additions & 0 deletions app/src/components/map/FwaNameSearch.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,8 @@ export default function FwaNameSearch({ onPickedFeature, fwaStyles }) {
<div ref={containerRef} className="fwa-search">
<div className="fwa-input-wrap">
<input
role="combobox"
aria-label="Search river or lake name"
placeholder="Search river or lake name…"
value={q}
onChange={(e) => {
Expand Down
Loading
Loading