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
5 changes: 4 additions & 1 deletion forum/ext/teasel/auth0/event/subscriber.php
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ protected function ensure_oauth_mapping($event)

if ($user_id === 0) {
// Create a new phpBB user using nickname as username
global $phpbb_root_path, $phpEx;
global $phpbb_root_path, $phpEx, $config;
if (!function_exists('user_add')) {
include_once($phpbb_root_path.'includes/functions_user.'.$phpEx);
}
Expand Down Expand Up @@ -202,6 +202,9 @@ protected function ensure_oauth_mapping($event)
'user_actkey' => '', // No activation needed
'user_inactive_reason' => 0,
'user_inactive_time' => 0,
// user_add() defaults this to 0; ucp_register sets it explicitly, so must we,
// otherwise new users skip the Newly Registered Users group (post moderation)
'user_new' => ($config['new_member_post_limit']) ? 1 : 0,
];

$newId = user_add($userData, false); // false = suppress validation error array
Expand Down
3 changes: 3 additions & 0 deletions forum/ext/teasel/auth0/service/provider.php
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,9 @@ protected function ensure_oauth_mapping()
'user_actkey' => '', // No activation needed
'user_inactive_reason' => 0,
'user_inactive_time' => 0,
// user_add() defaults this to 0; ucp_register sets it explicitly, so must we,
// otherwise new users skip the Newly Registered Users group (post moderation)
'user_new' => ($this->config['new_member_post_limit']) ? 1 : 0,
];

$newId = user_add($userData, false); // false = suppress validation error array
Expand Down
109 changes: 89 additions & 20 deletions web/src/components/experiment/TrigsV2Map.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,13 @@
* viewport and switches to a heatmap when too many would be visible.
*/

import { useEffect, useMemo, useState } from "react";
import { useMap } from "react-leaflet";
import { useCallback, useEffect, useMemo, useRef, useState, type MutableRefObject } from "react";
import { CircleMarker, Tooltip, useMap } from "react-leaflet";
import { latLngBounds } from "leaflet";
import BaseMap from "../map/BaseMap";
import TrigMarker from "../map/TrigMarker";
import HeatmapLayer from "../map/HeatmapLayer";
import TilesetSelector from "../map/TilesetSelector";
import AddToListButton from "../lists/AddToListButton";
import type { MapBounds, TrigData } from "../map/types";
import {
Expand All @@ -24,7 +25,18 @@ import {
// Above this many markers in view, show a density heatmap instead
const MAX_VISIBLE_MARKERS = 1000;

function ViewportTracker({ onChange }: { onChange: (bounds: MapBounds) => void }) {
interface MapView {
center: [number, number];
zoom: number;
}

function ViewportTracker({
onChange,
viewRef,
}: {
onChange: (bounds: MapBounds) => void;
viewRef: MutableRefObject<MapView>;
}) {
const map = useMap();

useEffect(() => {
Expand All @@ -36,13 +48,15 @@ function ViewportTracker({ onChange }: { onChange: (bounds: MapBounds) => void }
east: bounds.getEast(),
west: bounds.getWest(),
});
const center = map.getCenter();
viewRef.current = { center: [center.lat, center.lng], zoom: map.getZoom() };
};
update();
map.on("moveend", update);
return () => {
map.off("moveend", update);
};
}, [map, onChange]);
}, [map, onChange, viewRef]);

return null;
}
Expand All @@ -52,11 +66,23 @@ function ViewportTracker({ onChange }: { onChange: (bounds: MapBounds) => void }
// few bad positions can't drag the map off the UK.
const FIT_REGION = { south: 49, north: 61.5, west: -11, east: 2.5 };

/** Zoom to fit the trigpoints whenever the filtered set changes. */
function FitToTrigs({ trigs }: { trigs: TrigData[] }) {
/**
* Zoom to fit the trigpoints whenever the filtered set changes. `fittedRef`
* lives outside the map, so a remount (e.g. switching to a layer with a
* different projection) keeps the user's view rather than fitting again.
*/
function FitToTrigs({
trigs,
fittedRef,
}: {
trigs: TrigData[];
fittedRef: MutableRefObject<TrigData[] | null>;
}) {
const map = useMap();

useEffect(() => {
if (fittedRef.current === trigs) return;
fittedRef.current = trigs;
const points = trigs
.map((t) => [Number(t.wgs_lat), Number(t.wgs_long)] as [number, number])
.filter(
Expand All @@ -68,7 +94,7 @@ function FitToTrigs({ trigs }: { trigs: TrigData[] }) {
);
if (points.length === 0) return;
map.fitBounds(latLngBounds(points), { padding: [24, 24], maxZoom: 13 });
}, [map, trigs]);
}, [map, trigs, fittedRef]);

return null;
}
Expand All @@ -79,6 +105,8 @@ export interface TrigsV2MapProps {
error: Error | null;
truncated: boolean;
showListActions: boolean;
/** The page's Location, marked on the map */
location?: { lat: number; lon: number; name: string };
}

export function TrigsV2Map({
Expand All @@ -87,19 +115,45 @@ export function TrigsV2Map({
error,
truncated,
showListActions,
location,
}: TrigsV2MapProps) {
const [tileLayerId] = useState(getPreferredTileLayer);
const [tileLayerId, setTileLayerId] = useState(getPreferredTileLayer);
const [bounds, setBounds] = useState<MapBounds | null>(null);
const fittedRef = useRef<TrigData[] | null>(null);

const initialZoom = useMemo(() => {
// The view the map starts from - only read when it mounts, i.e. first time
// and whenever a change of projection remounts it
const [startView, setStartView] = useState<MapView>(() => {
const layer = getTileLayer(tileLayerId);
return calculateProjectionZoom(
MAP_CONFIG.defaultZoom,
"EPSG:3857",
layer.crs || "EPSG:3857",
layer,
);
}, [tileLayerId]);
return {
center: [MAP_CONFIG.defaultCenter.lat, MAP_CONFIG.defaultCenter.lng],
zoom: calculateProjectionZoom(
MAP_CONFIG.defaultZoom,
"EPSG:3857",
layer.crs || "EPSG:3857",
layer,
),
};
});
const viewRef = useRef<MapView>(startView);

// Keep the current view across a projection change (zoom levels differ
// between projections, so convert it)
const handleTilesetChange = useCallback(
(newTileLayerId: string) => {
const currentCrs = getTileLayer(tileLayerId).crs || "EPSG:3857";
const newLayer = getTileLayer(newTileLayerId);
const newCrs = newLayer.crs || "EPSG:3857";
if (currentCrs !== newCrs) {
setStartView({
center: viewRef.current.center,
zoom: calculateProjectionZoom(viewRef.current.zoom, currentCrs, newCrs, newLayer),
});
}
setTileLayerId(newTileLayerId);
},
[tileLayerId],
);

const visibleTrigs = useMemo(() => {
if (!bounds) return trigs;
Expand Down Expand Up @@ -146,13 +200,13 @@ export function TrigsV2Map({
{/* relative z-0 contains Leaflet's pane z-indexes so the sticky footer stays on top */}
<div className="relative z-0 rounded-lg overflow-hidden shadow dark:shadow-gray-900/50">
<BaseMap
center={[MAP_CONFIG.defaultCenter.lat, MAP_CONFIG.defaultCenter.lng]}
zoom={initialZoom}
center={startView.center}
zoom={startView.zoom}
height="70vh"
tileLayerId={tileLayerId}
>
<ViewportTracker onChange={setBounds} />
<FitToTrigs trigs={trigs} />
<ViewportTracker onChange={setBounds} viewRef={viewRef} />
<FitToTrigs trigs={trigs} fittedRef={fittedRef} />
{showHeatmap ? (
<HeatmapLayer trigpoints={trigs} />
) : (
Expand All @@ -165,7 +219,22 @@ export function TrigsV2Map({
/>
))
)}
{location && (
// Same blue as the location circle on the popup mini-maps
<CircleMarker
center={[location.lat, location.lon]}
radius={10}
pathOptions={{ color: "#2563eb", weight: 2, fillColor: "#3b82f6", fillOpacity: 0.3 }}
>
{location.name && <Tooltip direction="top">{location.name}</Tooltip>}
</CircleMarker>
)}
</BaseMap>

{/* Above Leaflet's controls (z-index 1000) */}
<div className="absolute top-2 right-2 z-[1001]">
<TilesetSelector value={tileLayerId} onChange={handleTilesetChange} />
</div>
</div>
</div>
);
Expand Down
12 changes: 11 additions & 1 deletion web/src/components/trigs/DownloadButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,19 @@ interface DownloadButtonProps {
order?: string;
/** Whose logs "include log data" refers to, when not the signed-in user */
logUserName?: string;
/** "subtle" for a quiet text button beside more important controls */
variant?: "primary" | "subtle";
/** Additional CSS classes */
className?: string;
}

const BUTTON_STYLES = {
primary:
"gap-2 px-4 py-2 font-medium text-white bg-green-600 hover:bg-green-700 rounded-lg shadow-sm",
subtle:
"gap-1.5 px-2 py-1.5 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg",
};

type DownloadFormat = "csv" | "geojson" | "kml" | "kmz" | "gpx";

interface FormatOption {
Expand Down Expand Up @@ -59,6 +68,7 @@ export function DownloadButton({
filterParams,
order,
logUserName,
variant = "primary",
className = "",
}: DownloadButtonProps) {
const [isOpen, setIsOpen] = useState(false);
Expand Down Expand Up @@ -181,7 +191,7 @@ export function DownloadButton({
ref={refs.setReference}
type="button"
onClick={() => setIsOpen(!isOpen)}
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-green-600 hover:bg-green-700 rounded-lg shadow-sm transition-colors disabled:opacity-50"
className={`inline-flex items-center text-sm transition-colors disabled:opacity-50 ${BUTTON_STYLES[variant]}`}
disabled={isLoading}
>
{isLoading ? (
Expand Down
48 changes: 30 additions & 18 deletions web/src/routes/experiment/TrigsV2.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@ const DEFAULT_LAT = 53.2585;
const DEFAULT_LON = -1.9106;
const DEFAULT_LOCATION_NAME = "Buxton";

// What LocationSearch calls the device's own location
const DEVICE_LOCATION_NAME = "Current location";

export default function TrigsV2() {
const [searchParams, setSearchParams] = useSearchParams();
const { isAuthenticated } = useAuth0();
Expand Down Expand Up @@ -113,18 +116,21 @@ export default function TrigsV2() {
// Filter State
// ==========================================================================

// Location
const [centerLat, setCenterLat] = useState<number | null>(() => {
// Location. Only a place the user picked is kept in the URL; otherwise the
// page uses the device's location afresh on each visit (falling back to
// Buxton), so refreshes follow the device and shared links don't carry the
// sharer's whereabouts. Older links saved "Current location" coordinates -
// those are ignored in favour of the device.
const [urlLocation] = useState(() => {
const lat = parseFloat(searchParams.get("lat") || "");
return lat || null;
});
const [centerLon, setCenterLon] = useState<number | null>(() => {
const lon = parseFloat(searchParams.get("lon") || "");
return lon || null;
const name = searchParams.get("location") || "";
return lat && lon && name !== DEVICE_LOCATION_NAME ? { lat, lon, name } : null;
});
const [locationName, setLocationName] = useState<string>(
() => searchParams.get("location") || ""
);
const [centerLat, setCenterLat] = useState<number | null>(urlLocation?.lat ?? null);
const [centerLon, setCenterLon] = useState<number | null>(urlLocation?.lon ?? null);
const [locationName, setLocationName] = useState<string>(urlLocation?.name ?? "");
const [locationChosen, setLocationChosen] = useState(urlLocation !== null);

// Categories (status IDs: 10=Pillar, 20=FBM, etc.)
const [selectedCategories, setSelectedCategories] = useState<number[]>(() =>
Expand Down Expand Up @@ -263,13 +269,16 @@ export default function TrigsV2() {
(position) => {
setCenterLat(position.coords.latitude);
setCenterLon(position.coords.longitude);
setLocationName("Current location");
setLocationName(DEVICE_LOCATION_NAME);
},
// Blocked, or no fix in time
() => {
setCenterLat(DEFAULT_LAT);
setCenterLon(DEFAULT_LON);
setLocationName(DEFAULT_LOCATION_NAME);
}
},
// A fix from the last few minutes is fine; don't hang on a slow one
{ maximumAge: 5 * 60 * 1000, timeout: 15 * 1000 }
);
}, [centerLat]);

Expand All @@ -282,6 +291,7 @@ export default function TrigsV2() {
setCenterLat(lat);
setCenterLon(lon);
setLocationName(name);
setLocationChosen(name !== DEVICE_LOCATION_NAME);
},
[]
);
Expand Down Expand Up @@ -380,14 +390,10 @@ export default function TrigsV2() {

const params = new URLSearchParams();

// Location
if (centerLat !== null) {
// Location, only if the user picked it
if (locationChosen && centerLat !== null && centerLon !== null) {
params.set("lat", centerLat.toFixed(5));
}
if (centerLon !== null) {
params.set("lon", centerLon.toFixed(5));
}
if (locationName) {
params.set("location", locationName);
}

Expand Down Expand Up @@ -438,7 +444,7 @@ export default function TrigsV2() {
// Update URL without triggering navigation
setSearchParams(params, { replace: true });
}, [
filtersReady, centerLat, centerLon, locationName, maxKm, sortKey, sortDirection,
filtersReady, locationChosen, centerLat, centerLon, locationName, maxKm, sortKey, sortDirection,
selectedCategories, selectedTypes, selectedConditions, selectedHistoricUse,
selectedCurrentUse, selectedAreaIds, allTypeCodes, allConditionCodes,
allHistoricUseValues, allCurrentUseValues, logUser, view,
Expand Down Expand Up @@ -804,6 +810,7 @@ export default function TrigsV2() {
filterParams={buildTrigFilterParams(filterOptions)}
order={orderParam}
logUserName={logUser?.name}
variant="subtle"
/>
)}
</div>
Expand All @@ -818,6 +825,11 @@ export default function TrigsV2() {
error={mapError}
truncated={mapPoints?.truncated ?? false}
showListActions={showListActions}
location={
centerLat !== null && centerLon !== null
? { lat: centerLat, lon: centerLon, name: locationName }
: undefined
}
/>
)}

Expand Down
Loading