Drone and PPR and operational updates

This commit is contained in:
2026-06-20 05:31:04 -04:00
parent b8d7cdddcd
commit 3e8b3cf4c1
15 changed files with 1727 additions and 39 deletions
+724
View File
@@ -0,0 +1,724 @@
---
const configuredApiBase = import.meta.env.PUBLIC_PPR_API_BASE ?? 'https://ppr.swansea-airport.wales/api/v1';
const pprApiBase = configuredApiBase.replace(/\/$/, '');
const droneRequestEndpoint = `${pprApiBase}/drone-requests/public`;
const frzGeoJsonEndpoint = `${pprApiBase}/drone-requests/frz`;
---
<section class="drone-shell surface" aria-labelledby="drone-heading">
<div class="drone-head">
<p class="eyebrow">Drone flight request</p>
<h2 id="drone-heading" class="section-title">Request a flight within the FRZ</h2>
<p class="section-copy">
Tell us when and where you plan to fly. Pick the operating location on the map or enter it
manually, then click Submit. We will review your request and contact you if we need any further information.
</p>
</div>
<form id="drone-form" class="drone-form">
<div class="drone-grid">
<div class="drone-field">
<label for="operator-name">Operator Name <span aria-hidden="true">*</span></label>
<input type="text" id="operator-name" name="operator_name" autocomplete="organization" required />
</div>
<div class="drone-field">
<label for="operator-id">Operator ID <span aria-hidden="true">*</span></label>
<input type="text" id="operator-id" name="operator_id" autocomplete="off" required />
</div>
<div class="drone-field">
<label for="flight-date">Date of Flight <span aria-hidden="true">*</span></label>
<input type="date" id="flight-date" name="flight_date" required />
</div>
<div class="drone-field">
<label for="takeoff-time">Estimated Take Off Time <span aria-hidden="true">*</span></label>
<select id="takeoff-time" name="estimated_takeoff_time" required>
<option value="">Select Time</option>
</select>
</div>
<div class="drone-field">
<label for="completion-time">Estimated Completion Time <span aria-hidden="true">*</span></label>
<select id="completion-time" name="estimated_completion_time" required>
<option value="">Select Time</option>
</select>
</div>
<div class="drone-field">
<label for="max-elevation">Maximum Elevation, feet AMSL <span aria-hidden="true">*</span></label>
<input type="number" id="max-elevation" name="maximum_elevation_ft_amsl" min="0" step="1" inputmode="numeric" required />
</div>
<div class="drone-field drone-full">
<label for="location-description">Location <span aria-hidden="true">*</span></label>
<input
type="text"
id="location-description"
name="location_description"
placeholder="Site name, address, grid reference, or map-selected coordinates"
autocomplete="off"
required
/>
</div>
<div class="drone-map-panel drone-full">
<div class="drone-map-toolbar">
<div>
<p class="drone-map-title">Operating Location</p>
<p id="map-status" class="drone-map-status">Click the map to drop the requested flight location.</p>
</div>
<button type="button" class="button secondary" id="clear-location">Clear Location</button>
</div>
<div id="drone-map" class="drone-map" aria-label="Clickable map for selecting drone operating location"></div>
<div class="drone-field drone-map-location">
<label for="map-location-summary">Selected Map Coordinates</label>
<input
type="text"
id="map-location-summary"
placeholder="No map location selected"
readonly
/>
</div>
<div class="drone-map-legend" aria-label="Map overlay legend">
<span><i class="legend-swatch frz"></i>Flight restriction zone</span>
<span><i class="legend-swatch selected"></i>Selected location</span>
</div>
<input type="hidden" id="location-lat" name="location_latitude" />
<input type="hidden" id="location-lng" name="location_longitude" />
<input type="hidden" id="location-inside-frz" name="location_inside_frz" />
</div>
<div class="drone-field">
<label for="flyer-name">Flyer Name <span aria-hidden="true">*</span></label>
<input type="text" id="flyer-name" name="flyer_name" autocomplete="name" required />
</div>
<div class="drone-field">
<label for="flyer-id">Flyer ID <span aria-hidden="true">*</span></label>
<input type="text" id="flyer-id" name="flyer_id" autocomplete="off" required />
</div>
<div class="drone-field">
<label for="email">Email Address <span aria-hidden="true">*</span></label>
<input type="email" id="email" name="email" autocomplete="email" required />
</div>
<div class="drone-field">
<label for="phone">Phone Number <span aria-hidden="true">*</span></label>
<input type="tel" id="phone" name="phone" autocomplete="tel" required />
</div>
<div class="drone-field drone-full">
<label for="notes">Operational Notes</label>
<textarea id="notes" name="notes" rows="4" placeholder="Purpose of flight, aircraft type, contact on site, or other useful details..."></textarea>
</div>
</div>
<div class="drone-actions">
<button type="submit" class="button primary" id="submit-btn">Submit Drone Request</button>
</div>
</form>
<div class="drone-loading" id="loading" role="status" aria-live="polite">
<span class="drone-spinner" aria-hidden="true"></span>
Submitting your drone request...
</div>
<div class="drone-success notice" id="success-message" role="status" aria-live="polite">
<h3>Drone Request Submitted.</h3>
<p>Your drone request has been submitted. We will review it and contact you if we need any further information.</p>
</div>
</section>
<div id="notification" class="drone-notification" role="status" aria-live="polite"></div>
<script define:vars={{ droneRequestEndpoint, frzGeoJsonEndpoint }}>
(() => {
const DRONE_REQUEST_ENDPOINT = droneRequestEndpoint;
const FRZ_GEOJSON_ENDPOINT = frzGeoJsonEndpoint;
const LEAFLET_CSS = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css';
const LEAFLET_JS = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js';
const ARP = { lat: 51.6053, lng: -4.0678 };
let map;
let selectedMarker;
let frzGeoJson;
const get = (id) => document.getElementById(id);
function loadStylesheet(href) {
if (document.querySelector(`link[href="${href}"]`)) return;
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = href;
document.head.append(link);
}
function loadScript(src) {
return new Promise((resolve, reject) => {
if (window.L) {
resolve();
return;
}
const existingScript = document.querySelector(`script[src="${src}"]`);
if (existingScript) {
existingScript.addEventListener('load', resolve, { once: true });
existingScript.addEventListener('error', reject, { once: true });
return;
}
const script = document.createElement('script');
script.src = src;
script.async = true;
script.onload = resolve;
script.onerror = reject;
document.head.append(script);
});
}
function initializeTimeDropdowns() {
['takeoff-time', 'completion-time'].forEach((selectId) => {
const select = get(selectId);
select.replaceChildren(new Option('Select Time', ''));
for (let hour = 0; hour < 24; hour += 1) {
for (let minute = 0; minute < 60; minute += 15) {
const value = `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
select.append(new Option(value, value));
}
}
});
}
function formatDate(date) {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
}
function formatTime(date) {
return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`;
}
function setDefaultDateTime() {
const takeoff = new Date();
takeoff.setHours(takeoff.getHours() + 1, 0, 0, 0);
const completion = new Date(takeoff);
completion.setHours(completion.getHours() + 1);
get('flight-date').value = formatDate(takeoff);
get('takeoff-time').value = formatTime(takeoff);
get('completion-time').value = formatTime(completion);
}
function setMapStatus(message) {
get('map-status').textContent = message;
}
function isPointInRing(latlng, ring) {
if (!Array.isArray(ring) || ring.length < 3) return false;
const x = latlng.lng;
const y = latlng.lat;
let inside = false;
for (let i = 0, j = ring.length - 1; i < ring.length; j = i, i += 1) {
const xi = ring[i][0];
const yi = ring[i][1];
const xj = ring[j][0];
const yj = ring[j][1];
const intersects = ((yi > y) !== (yj > y))
&& (x < ((xj - xi) * (y - yi)) / (yj - yi) + xi);
if (intersects) inside = !inside;
}
return inside;
}
function isPointInPolygon(latlng, polygon) {
if (!polygon.length || !isPointInRing(latlng, polygon[0])) return false;
return !polygon.slice(1).some((hole) => isPointInRing(latlng, hole));
}
function isPointInGeometry(latlng, geometry) {
if (!geometry) return false;
if (geometry.type === 'Polygon') {
return isPointInPolygon(latlng, geometry.coordinates);
}
if (geometry.type === 'MultiPolygon') {
return geometry.coordinates.some((polygon) => isPointInPolygon(latlng, polygon));
}
if (geometry.type === 'GeometryCollection') {
return Array.isArray(geometry.geometries)
&& geometry.geometries.some((item) => isPointInGeometry(latlng, item));
}
return false;
}
function isPointInGeoJson(latlng, geoJson) {
if (!geoJson) return null;
if (geoJson.type === 'FeatureCollection') {
return Array.isArray(geoJson.features)
&& geoJson.features.some((feature) => isPointInGeometry(latlng, feature.geometry));
}
if (geoJson.type === 'Feature') {
return isPointInGeometry(latlng, geoJson.geometry);
}
return isPointInGeometry(latlng, geoJson);
}
function selectLocation(latlng) {
const latitude = latlng.lat.toFixed(6);
const longitude = latlng.lng.toFixed(6);
const insideFrz = isPointInGeoJson(latlng, frzGeoJson);
const status = insideFrz === null
? 'FRZ status unavailable'
: `${insideFrz ? 'inside' : 'outside'} the flight restriction zone`;
if (selectedMarker) {
selectedMarker.setLatLng(latlng);
} else {
selectedMarker = window.L.marker(latlng, { draggable: true }).addTo(map);
selectedMarker.on('dragend', (event) => selectLocation(event.target.getLatLng()));
}
selectedMarker.bindPopup(`Selected location<br>${latitude}, ${longitude}<br>${status}`).openPopup();
get('location-lat').value = latitude;
get('location-lng').value = longitude;
get('location-inside-frz').value = insideFrz === null ? '' : insideFrz ? 'yes' : 'no';
get('location-description').value = `${latitude}, ${longitude}`;
get('map-location-summary').value = `${latitude}, ${longitude} (${status})`;
setMapStatus(`Selected ${latitude}, ${longitude} - ${status}.`);
}
function clearLocation() {
if (selectedMarker) {
selectedMarker.remove();
selectedMarker = null;
}
get('location-lat').value = '';
get('location-lng').value = '';
get('location-inside-frz').value = '';
get('location-description').value = '';
get('map-location-summary').value = '';
setMapStatus('Click the map to drop the requested flight location.');
}
async function loadFrzGeoJsonOverlay() {
setMapStatus('Loading flight restriction zone...');
try {
const response = await fetch(FRZ_GEOJSON_ENDPOINT);
if (!response.ok) {
throw new Error(`FRZ request failed: ${response.status}`);
}
frzGeoJson = await response.json();
const frzLayer = window.L.geoJSON(frzGeoJson, {
interactive: false,
style: {
color: '#6c2bd9',
fillColor: '#6c2bd9',
fillOpacity: 0.12,
weight: 2,
},
pointToLayer: (_feature, latlng) => window.L.circleMarker(latlng, {
radius: 5,
color: '#6c2bd9',
fillColor: '#ffffff',
fillOpacity: 1,
weight: 2,
}),
}).addTo(map);
const bounds = frzLayer.getBounds();
if (bounds.isValid()) {
map.fitBounds(bounds.pad(0.12));
}
setMapStatus('Click the map to drop the requested flight location.');
} catch (error) {
console.error('FRZ GeoJSON failed to load:', error);
frzGeoJson = null;
map.setView([ARP.lat, ARP.lng], 13);
setMapStatus('FRZ overlay could not be loaded. Enter the location manually or click the map to capture coordinates.');
}
}
async function initializeMap() {
loadStylesheet(LEAFLET_CSS);
await loadScript(LEAFLET_JS);
map = window.L.map('drone-map', {
scrollWheelZoom: false,
}).setView([ARP.lat, ARP.lng], 13);
window.L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '&copy; OpenStreetMap contributors',
}).addTo(map);
window.L.circleMarker([ARP.lat, ARP.lng], {
radius: 5,
color: '#102233',
fillColor: '#ffffff',
fillOpacity: 1,
weight: 2,
}).bindPopup('Swansea Airport ARP').addTo(map);
await loadFrzGeoJsonOverlay();
map.on('click', (event) => selectLocation(event.latlng));
get('clear-location').addEventListener('click', clearLocation);
}
function showNotification(message, isError = false) {
const notification = get('notification');
notification.textContent = message;
notification.className = `drone-notification${isError ? ' error' : ''}`;
requestAnimationFrame(() => notification.classList.add('show'));
window.setTimeout(() => notification.classList.remove('show'), 5000);
}
function buildDroneRequestData(form) {
const formData = new FormData(form);
const data = {};
formData.forEach((value, key) => {
const fieldValue = String(value).trim();
if (!fieldValue) return;
if (key === 'maximum_elevation_ft_amsl') {
data[key] = Number.parseInt(fieldValue, 10);
} else if (key === 'location_latitude' || key === 'location_longitude') {
data[key] = Number.parseFloat(fieldValue);
} else {
data[key] = fieldValue;
}
});
if (formData.get('flight_date') && formData.get('estimated_takeoff_time')) {
data.estimated_takeoff_at = new Date(`${formData.get('flight_date')}T${formData.get('estimated_takeoff_time')}`).toISOString();
}
if (formData.get('flight_date') && formData.get('estimated_completion_time')) {
data.estimated_completion_at = new Date(`${formData.get('flight_date')}T${formData.get('estimated_completion_time')}`).toISOString();
}
return data;
}
async function handleSubmit(event) {
event.preventDefault();
const form = event.currentTarget;
const submitButton = get('submit-btn');
const payload = buildDroneRequestData(form);
get('loading').style.display = 'flex';
submitButton.disabled = true;
submitButton.textContent = 'Submitting...';
try {
const response = await fetch(DRONE_REQUEST_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail || `Submission failed: ${response.status}`);
}
form.style.display = 'none';
get('success-message').style.display = 'block';
showNotification('Drone request submitted successfully!');
} catch (error) {
console.error('Error submitting drone request:', error);
showNotification(`Error submitting drone request: ${error.message}`, true);
} finally {
get('loading').style.display = 'none';
submitButton.disabled = false;
submitButton.textContent = 'Submit Drone Request';
}
}
document.addEventListener('DOMContentLoaded', () => {
initializeTimeDropdowns();
setDefaultDateTime();
get('drone-form').addEventListener('submit', handleSubmit);
initializeMap().catch((error) => {
console.error('Leaflet map failed to load:', error);
setMapStatus('Map could not be loaded. Enter the location manually.');
});
});
})();
</script>
<style>
.drone-shell {
display: grid;
gap: 1.25rem;
margin-block: 1.5rem 0;
}
.drone-head {
display: grid;
gap: 0.55rem;
}
.drone-note {
margin: 0;
color: var(--muted);
font-size: 0.95rem;
}
.drone-form {
display: grid;
gap: 1.2rem;
}
.drone-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
}
.drone-field {
display: grid;
gap: 0.4rem;
align-content: start;
}
.drone-full {
grid-column: 1 / -1;
}
.drone-field label {
color: var(--text);
font-size: 0.92rem;
font-weight: 800;
}
.drone-field label span {
color: var(--critical);
}
.drone-field input,
.drone-field select,
.drone-field textarea {
width: 100%;
min-height: 2.85rem;
border: 1px solid rgba(16, 34, 51, 0.16);
border-radius: 0.7rem;
background: rgba(255, 255, 255, 0.88);
color: var(--text);
font: inherit;
font-size: 1rem;
padding: 0.7rem 0.82rem;
}
.drone-field textarea {
min-height: 7.5rem;
resize: vertical;
}
.drone-field input:focus,
.drone-field select:focus,
.drone-field textarea:focus {
outline: none;
border-color: var(--brand-2);
box-shadow: 0 0 0 0.2rem rgba(29, 118, 184, 0.16);
}
.drone-map-panel {
display: grid;
gap: 0.7rem;
padding: 0.85rem;
border: 1px solid rgba(16, 34, 51, 0.12);
border-radius: 0.8rem;
background: rgba(255, 255, 255, 0.6);
}
.drone-map-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.drone-map-title {
margin: 0;
color: var(--text);
font-weight: 800;
}
.drone-map-status {
margin: 0.1rem 0 0;
color: var(--muted);
font-size: 0.9rem;
}
.drone-map {
width: 100%;
min-height: 31rem;
border: 1px solid rgba(16, 34, 51, 0.14);
border-radius: 0.75rem;
overflow: hidden;
background: #dcecff;
z-index: 1;
}
.drone-map-location input {
background: rgba(255, 255, 255, 0.72);
color: var(--muted);
}
.drone-map-legend {
display: flex;
flex-wrap: wrap;
gap: 0.65rem 1rem;
color: var(--muted);
font-size: 0.88rem;
}
.drone-map-legend span {
display: inline-flex;
align-items: center;
gap: 0.35rem;
}
.legend-swatch {
width: 0.8rem;
height: 0.8rem;
border-radius: 0.2rem;
display: inline-block;
}
.legend-swatch.frz {
border: 2px solid #6c2bd9;
background: rgba(108, 43, 217, 0.12);
}
.legend-swatch.rpz {
border: 2px solid #7c3aed;
background: rgba(124, 58, 237, 0.18);
}
.legend-swatch.selected {
border-radius: 50%;
border: 2px solid #102233;
background: #ffffff;
}
.drone-actions {
display: flex;
justify-content: flex-start;
padding-top: 0.4rem;
}
.drone-actions .button,
.drone-map-toolbar .button {
cursor: pointer;
}
.drone-actions .button {
border: 0;
}
.drone-actions .button:disabled {
cursor: wait;
opacity: 0.7;
}
.drone-loading {
display: none;
align-items: center;
gap: 0.7rem;
color: var(--brand);
font-weight: 800;
}
.drone-spinner {
width: 1.45rem;
height: 1.45rem;
border: 3px solid rgba(29, 118, 184, 0.18);
border-top-color: var(--brand-2);
border-radius: 50%;
animation: drone-spin 0.8s linear infinite;
}
.drone-success {
display: none;
border-left-color: #257b4c;
}
.drone-notification {
position: fixed;
top: 1rem;
right: 1rem;
z-index: 60;
max-width: min(24rem, calc(100vw - 2rem));
padding: 0.85rem 1rem;
border-radius: 0.8rem;
background: #257b4c;
color: white;
box-shadow: var(--shadow);
font-weight: 800;
opacity: 0;
pointer-events: none;
transform: translateY(-0.65rem);
transition: opacity 0.18s ease, transform 0.18s ease;
}
.drone-notification.show {
opacity: 1;
transform: translateY(0);
}
.drone-notification.error {
background: var(--critical);
}
@keyframes drone-spin {
to {
transform: rotate(360deg);
}
}
@media (max-width: 720px) {
.drone-grid {
grid-template-columns: 1fr;
}
.drone-map-toolbar {
align-items: stretch;
flex-direction: column;
}
.drone-map {
min-height: 25rem;
}
.drone-actions,
.drone-actions .button,
.drone-map-toolbar .button {
width: 100%;
}
}
</style>
+669
View File
@@ -0,0 +1,669 @@
---
const configuredApiBase = import.meta.env.PUBLIC_PPR_API_BASE ?? 'https://ppr.swansea-airport.wales/api/v1';
const pprApiBase = configuredApiBase.replace(/\/$/, '');
---
<section class="ppr-shell surface" aria-labelledby="ppr-heading">
<div class="ppr-head">
<p class="eyebrow">Prior permission request</p>
<h2 id="ppr-heading" class="section-title">Submit a PPR request</h2>
<p class="section-copy">
Complete the details below for flights into Swansea Airport. Requests are accepted by default;
the airport will contact you if additional information is required.
</p>
<p class="ppr-note">
This form is under test. If you have any issues, email
<a href="mailto:james.pattinson@sasalliance.org">james.pattinson@sasalliance.org</a>.
</p>
</div>
<form id="ppr-form" class="ppr-form">
<div class="ppr-grid">
<div class="ppr-field">
<label for="ac_reg">Aircraft Registration <span aria-hidden="true">*</span></label>
<input type="text" id="ac_reg" name="ac_reg" autocomplete="off" required />
<div id="aircraft-lookup-results" class="ppr-lookup" aria-live="polite"></div>
</div>
<div class="ppr-field">
<label for="ac_type">Aircraft Type <span aria-hidden="true">*</span></label>
<input type="text" id="ac_type" name="ac_type" autocomplete="off" required />
</div>
<div class="ppr-field">
<label for="ac_call">Callsign</label>
<input type="text" id="ac_call" name="ac_call" placeholder="If different from registration" />
</div>
<div class="ppr-field">
<label for="captain">Captain/Pilot Name <span aria-hidden="true">*</span></label>
<input type="text" id="captain" name="captain" autocomplete="name" required />
</div>
<div class="ppr-field">
<label for="in_from">Arriving From <span aria-hidden="true">*</span></label>
<input type="text" id="in_from" name="in_from" placeholder="ICAO code or airport name" autocomplete="off" required />
<div id="arrival-airport-lookup-results" class="ppr-lookup" aria-live="polite"></div>
</div>
<div class="ppr-field">
<label for="eta-date">Estimated Time of Arrival (Local Time) <span aria-hidden="true">*</span></label>
<div class="ppr-date-time">
<input type="date" id="eta-date" name="eta-date" required />
<select id="eta-time" name="eta-time" required>
<option value="">Select Time</option>
</select>
</div>
</div>
<div class="ppr-field">
<label for="pob_in">Persons on Board (Arrival) <span aria-hidden="true">*</span></label>
<input type="number" id="pob_in" name="pob_in" min="1" inputmode="numeric" required />
</div>
<div class="ppr-field">
<label for="fuel">Fuel Required</label>
<select id="fuel" name="fuel">
<option value="">None</option>
<option value="100LL">100LL</option>
<option value="JET A1">JET A1</option>
</select>
</div>
<div class="ppr-field">
<label for="out_to">Departing To</label>
<input type="text" id="out_to" name="out_to" placeholder="ICAO code or airport name" autocomplete="off" />
<div id="departure-airport-lookup-results" class="ppr-lookup" aria-live="polite"></div>
</div>
<div class="ppr-field">
<label for="etd-date">Estimated Time of Departure (Local Time)</label>
<div class="ppr-date-time">
<input type="date" id="etd-date" name="etd-date" />
<select id="etd-time" name="etd-time">
<option value="">Select Time</option>
</select>
</div>
</div>
<div class="ppr-field">
<label for="pob_out">Persons on Board (Departure)</label>
<input type="number" id="pob_out" name="pob_out" min="1" inputmode="numeric" />
</div>
<div class="ppr-field">
<label for="email">Email Address</label>
<input type="email" id="email" name="email" autocomplete="email" />
</div>
<div class="ppr-field">
<label for="phone">Phone Number</label>
<input type="tel" id="phone" name="phone" autocomplete="tel" />
</div>
<div class="ppr-field ppr-full">
<label for="notes">Additional Notes</label>
<textarea id="notes" name="notes" rows="4" placeholder="Any special requirements, handling instructions, or additional information..."></textarea>
</div>
</div>
<div class="ppr-actions">
<button type="submit" class="button primary" id="submit-btn">Submit PPR Request</button>
</div>
</form>
<div class="ppr-loading" id="loading" role="status" aria-live="polite">
<span class="ppr-spinner" aria-hidden="true"></span>
Submitting your PPR request...
</div>
<div class="ppr-success notice" id="success-message" role="status" aria-live="polite">
<h3>PPR Request Submitted.</h3>
<p>Your PPR request has been submitted. You will receive confirmation via email if provided.</p>
<p><strong>Please note:</strong> PPR requests are accepted by default. We will contact you if additional information is required. Remember to check NOTAMs before your flight.</p>
</div>
</section>
<div id="notification" class="ppr-notification" role="status" aria-live="polite"></div>
<script define:vars={{ pprApiBase }}>
(() => {
const API_BASE = pprApiBase;
let etdManuallyEdited = false;
let aircraftLookupTimeout;
let arrivalAirportLookupTimeout;
let departureAirportLookupTimeout;
const get = (id) => document.getElementById(id);
function setLookupStatus(results, message, className) {
results.replaceChildren();
if (!message) return;
const status = document.createElement('span');
status.className = className;
status.textContent = message;
results.append(status);
}
function createOptionList(items, renderItem) {
const list = document.createElement('div');
list.className = 'ppr-option-list';
items.forEach((item) => list.append(renderItem(item)));
return list;
}
function createAircraftOption(aircraft, fallbackRegistration) {
const registration = aircraft.registration || fallbackRegistration.toUpperCase();
const typeCode = aircraft.type_code || '';
const option = document.createElement('button');
option.type = 'button';
option.className = 'ppr-option';
option.addEventListener('mousedown', (event) => event.preventDefault());
option.addEventListener('click', () => selectAircraft(registration, typeCode));
const code = document.createElement('span');
code.className = 'ppr-option-code';
code.textContent = registration;
const details = document.createElement('span');
details.className = 'ppr-option-detail';
details.textContent = `${typeCode || 'Unknown'}${aircraft.model ? ` (${aircraft.model})` : ''}`;
option.append(code, details);
return option;
}
function createAirportOption(fieldId, airport) {
const option = document.createElement('button');
option.type = 'button';
option.className = 'ppr-option';
option.addEventListener('mousedown', (event) => event.preventDefault());
option.addEventListener('click', () => selectAirport(fieldId, airport.icao));
const code = document.createElement('span');
code.className = 'ppr-option-code';
code.textContent = `${airport.icao}/${airport.iata || ''}`;
const details = document.createElement('span');
details.className = 'ppr-option-detail';
details.textContent = `${airport.name}, ${airport.country}`;
option.append(code, details);
return option;
}
function selectAircraft(registration, typeCode) {
get('ac_reg').value = registration;
get('ac_type').value = typeCode;
get('aircraft-lookup-results').replaceChildren();
get('ac_reg').blur();
}
function selectAirport(fieldId, icaoCode) {
get(fieldId).value = icaoCode;
const resultsId = fieldId === 'in_from' ? 'arrival-airport-lookup-results' : 'departure-airport-lookup-results';
get(resultsId).replaceChildren();
get(fieldId).blur();
}
function initializeTimeDropdowns() {
['eta-time', 'etd-time'].forEach((selectId) => {
const select = get(selectId);
select.replaceChildren(new Option('Select Time', ''));
for (let hour = 0; hour < 24; hour += 1) {
for (let minute = 0; minute < 60; minute += 15) {
const timeString = `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
select.append(new Option(timeString, timeString));
}
}
});
}
function formatDate(date) {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
}
function formatTime(date) {
return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`;
}
function updateETDFromETA() {
if (etdManuallyEdited) return;
const etaDate = get('eta-date').value;
const etaTime = get('eta-time').value;
if (!etaDate || !etaTime) return;
const etd = new Date(new Date(`${etaDate}T${etaTime}`).getTime() + 2 * 60 * 60 * 1000);
get('etd-date').value = formatDate(etd);
get('etd-time').value = formatTime(etd);
}
function setDefaultDateTime() {
const nextHour = new Date();
nextHour.setHours(nextHour.getHours() + 1, 0, 0, 0);
const etd = new Date(nextHour);
etd.setHours(nextHour.getHours() + 2);
get('eta-date').value = formatDate(nextHour);
get('eta-time').value = formatTime(nextHour);
get('etd-date').value = formatDate(etd);
get('etd-time').value = formatTime(etd);
}
function showNotification(message, isError = false) {
const notification = get('notification');
notification.textContent = message;
notification.className = `ppr-notification${isError ? ' error' : ''}`;
requestAnimationFrame(() => notification.classList.add('show'));
window.setTimeout(() => notification.classList.remove('show'), 5000);
}
async function handleAircraftLookup(registration) {
clearTimeout(aircraftLookupTimeout);
const results = get('aircraft-lookup-results');
if (!registration || registration.length < 4) {
results.replaceChildren();
return;
}
setLookupStatus(results, 'Searching...', 'ppr-lookup-searching');
aircraftLookupTimeout = window.setTimeout(async () => {
try {
const response = await fetch(`${API_BASE}/aircraft/public/lookup/${registration.toUpperCase()}`);
if (!response.ok) {
results.replaceChildren();
return;
}
const data = await response.json();
if (!data || data.length === 0) {
setLookupStatus(results, 'No aircraft found with this registration', 'ppr-lookup-muted');
return;
}
if (data.length === 1) {
const aircraft = data[0];
get('ac_reg').value = aircraft.registration || registration.toUpperCase();
if (!get('ac_type').value) get('ac_type').value = aircraft.type_code || '';
}
if (data.length <= 10) {
results.replaceChildren(createOptionList(data, (item) => createAircraftOption(item, registration)));
return;
}
setLookupStatus(results, 'Too many matches, please be more specific', 'ppr-lookup-muted');
} catch (error) {
console.error('Aircraft lookup error:', error);
results.replaceChildren();
}
}, 500);
}
async function handleAirportLookup(query, fieldId, resultsId, timeoutSetter) {
const results = get(resultsId);
if (!query || query.length < 2) {
results.replaceChildren();
return;
}
setLookupStatus(results, 'Searching...', 'ppr-lookup-searching');
timeoutSetter(window.setTimeout(async () => {
try {
const response = await fetch(`${API_BASE}/airport/public/lookup/${query.toUpperCase()}`);
if (!response.ok) {
results.replaceChildren();
return;
}
const data = await response.json();
if (!data || data.length === 0) {
setLookupStatus(results, 'No airport found', 'ppr-lookup-muted');
return;
}
if (data.length <= 10) {
results.replaceChildren(createOptionList(data, (airport) => createAirportOption(fieldId, airport)));
return;
}
setLookupStatus(results, 'Too many matches, please be more specific', 'ppr-lookup-muted');
} catch (error) {
console.error('Airport lookup error:', error);
results.replaceChildren();
}
}, 500));
}
function buildPprData(form) {
const formData = new FormData(form);
const pprData = {};
formData.forEach((value, key) => {
const fieldValue = String(value).trim();
if (!fieldValue) return;
if (key === 'pob_in' || key === 'pob_out') {
pprData[key] = Number.parseInt(fieldValue, 10);
} else if (key === 'eta-date' && formData.get('eta-time')) {
pprData.eta = new Date(`${fieldValue}T${formData.get('eta-time')}`).toISOString();
} else if (key === 'etd-date' && formData.get('etd-time')) {
pprData.etd = new Date(`${fieldValue}T${formData.get('etd-time')}`).toISOString();
} else if (key !== 'eta-time' && key !== 'etd-time') {
pprData[key] = fieldValue;
}
});
return pprData;
}
async function handleSubmit(event) {
event.preventDefault();
const form = event.currentTarget;
const submitButton = get('submit-btn');
get('loading').style.display = 'flex';
submitButton.disabled = true;
submitButton.textContent = 'Submitting...';
try {
const response = await fetch(`${API_BASE}/pprs/public`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(buildPprData(form)),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail || `Submission failed: ${response.status}`);
}
form.style.display = 'none';
get('success-message').style.display = 'block';
showNotification('PPR request submitted successfully!');
} catch (error) {
console.error('Error submitting PPR:', error);
showNotification(`Error submitting PPR: ${error.message}`, true);
} finally {
get('loading').style.display = 'none';
submitButton.disabled = false;
submitButton.textContent = 'Submit PPR Request';
}
}
document.addEventListener('DOMContentLoaded', () => {
initializeTimeDropdowns();
setDefaultDateTime();
get('ac_reg').addEventListener('input', (event) => handleAircraftLookup(event.target.value));
get('ac_reg').addEventListener('blur', () => window.setTimeout(() => get('aircraft-lookup-results').replaceChildren(), 150));
get('in_from').addEventListener('input', (event) => {
clearTimeout(arrivalAirportLookupTimeout);
handleAirportLookup(event.target.value, 'in_from', 'arrival-airport-lookup-results', (timeout) => {
arrivalAirportLookupTimeout = timeout;
});
});
get('in_from').addEventListener('blur', () => window.setTimeout(() => get('arrival-airport-lookup-results').replaceChildren(), 150));
get('out_to').addEventListener('input', (event) => {
clearTimeout(departureAirportLookupTimeout);
handleAirportLookup(event.target.value, 'out_to', 'departure-airport-lookup-results', (timeout) => {
departureAirportLookupTimeout = timeout;
});
});
get('out_to').addEventListener('blur', () => window.setTimeout(() => get('departure-airport-lookup-results').replaceChildren(), 150));
get('eta-date').addEventListener('change', updateETDFromETA);
get('eta-time').addEventListener('change', updateETDFromETA);
get('etd-date').addEventListener('change', () => {
etdManuallyEdited = true;
});
get('etd-time').addEventListener('change', () => {
etdManuallyEdited = true;
});
get('ppr-form').addEventListener('submit', handleSubmit);
});
})();
</script>
<style>
.ppr-shell {
display: grid;
gap: 1.25rem;
margin-block: 1.5rem 0;
}
.ppr-head {
display: grid;
gap: 0.55rem;
}
.ppr-note {
margin: 0;
color: var(--muted);
font-size: 0.95rem;
}
.ppr-form {
display: grid;
gap: 1.2rem;
}
.ppr-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
}
.ppr-field {
display: grid;
gap: 0.4rem;
align-content: start;
}
.ppr-full {
grid-column: 1 / -1;
}
.ppr-field label {
color: var(--text);
font-size: 0.92rem;
font-weight: 800;
}
.ppr-field label span {
color: var(--critical);
}
.ppr-field input,
.ppr-field select,
.ppr-field textarea {
width: 100%;
min-height: 2.85rem;
border: 1px solid rgba(16, 34, 51, 0.16);
border-radius: 0.7rem;
background: rgba(255, 255, 255, 0.88);
color: var(--text);
font: inherit;
font-size: 1rem;
padding: 0.7rem 0.82rem;
}
.ppr-field textarea {
min-height: 7.5rem;
resize: vertical;
}
.ppr-field input:focus,
.ppr-field select:focus,
.ppr-field textarea:focus {
outline: none;
border-color: var(--brand-2);
box-shadow: 0 0 0 0.2rem rgba(29, 118, 184, 0.16);
}
.ppr-date-time {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(8rem, 0.72fr);
gap: 0.6rem;
}
.ppr-lookup {
color: var(--muted);
font-size: 0.88rem;
}
.ppr-lookup-searching {
color: var(--brand-2);
font-weight: 700;
}
.ppr-lookup-muted {
color: var(--muted);
font-style: italic;
}
.ppr-option-list {
display: grid;
max-height: 14rem;
overflow-y: auto;
border: 1px solid var(--line);
border-radius: 0.7rem;
background: var(--panel-strong);
box-shadow: 0 0.7rem 1.8rem rgba(16, 34, 51, 0.08);
}
.ppr-option {
display: flex;
justify-content: space-between;
gap: 0.75rem;
width: 100%;
padding: 0.7rem 0.8rem;
border: 0;
border-bottom: 1px solid rgba(16, 34, 51, 0.08);
background: transparent;
color: var(--text);
cursor: pointer;
font: inherit;
text-align: left;
}
.ppr-option:hover,
.ppr-option:focus {
outline: none;
background: var(--brand-soft);
}
.ppr-option:last-child {
border-bottom: 0;
}
.ppr-option-code {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-weight: 800;
}
.ppr-option-detail {
color: var(--muted);
font-size: 0.86rem;
text-align: right;
}
.ppr-actions {
display: flex;
justify-content: flex-start;
padding-top: 0.4rem;
}
.ppr-actions .button {
border: 0;
cursor: pointer;
}
.ppr-actions .button:disabled {
cursor: wait;
opacity: 0.7;
}
.ppr-loading {
display: none;
align-items: center;
gap: 0.7rem;
color: var(--brand);
font-weight: 800;
}
.ppr-spinner {
width: 1.45rem;
height: 1.45rem;
border: 3px solid rgba(29, 118, 184, 0.18);
border-top-color: var(--brand-2);
border-radius: 50%;
animation: ppr-spin 0.8s linear infinite;
}
.ppr-success {
display: none;
border-left-color: #257b4c;
}
.ppr-notification {
position: fixed;
top: 1rem;
right: 1rem;
z-index: 60;
max-width: min(24rem, calc(100vw - 2rem));
padding: 0.85rem 1rem;
border-radius: 0.8rem;
background: #257b4c;
color: white;
box-shadow: var(--shadow);
font-weight: 800;
opacity: 0;
pointer-events: none;
transform: translateY(-0.65rem);
transition: opacity 0.18s ease, transform 0.18s ease;
}
.ppr-notification.show {
opacity: 1;
transform: translateY(0);
}
.ppr-notification.error {
background: var(--critical);
}
@keyframes ppr-spin {
to {
transform: rotate(360deg);
}
}
@media (max-width: 720px) {
.ppr-grid,
.ppr-date-time {
grid-template-columns: 1fr;
}
.ppr-actions,
.ppr-actions .button {
width: 100%;
}
.ppr-option {
display: grid;
gap: 0.2rem;
}
.ppr-option-detail {
text-align: left;
}
}
</style>
+1 -1
View File
@@ -22,11 +22,11 @@ export const site = {
children: [
{ label: 'History', href: '/about/history/' },
{ label: 'Drones', href: '/about/drones/' },
{ label: 'Fees and Charges', href: '/about/fees-and-charges/' },
{ label: 'Noise', href: '/about/noise/' },
{ label: 'Volunteering', href: '/about/volunteering/' },
],
},
{ label: 'Procedures', href: '/procedures-safety-noise-abatement/' },
{ label: 'Events', href: '/events/' },
{ label: 'News', href: '/news/' },
{ label: 'Documents', href: '/documents/' },
+4
View File
@@ -23,6 +23,10 @@ import BaseLayout from '../../layouts/BaseLayout.astro';
much notice as possible via email, with the following information:
</p>
<p>
<a class="button primary" href="/drone-flight-request/">Request a Drone Flight</a>
</p>
<ul>
<li>Date of flight</li>
<li>Estimated take-off time</li>
+239
View File
@@ -0,0 +1,239 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
const gaCharges = [
['Microlight', '£10', 'Free of charge', '£10', '£20'],
['Standard GA Single', '£15', 'Free of charge', '£15', '£30'],
['GA Light Twin', '£25', 'Free of charge', '£15', '£30'],
['2500kg - 3000kg', '£60', 'Free of charge', '£30', 'Price on request'],
['3001kg - 4000kg', '£80', '5 hours free, £5 after', '£45', 'Price on request'],
['4001kg - 5000kg', '£180', '5 hours free, £10 after', '£60', 'Price on request'],
];
const touchAndGoCharges = [
['Microlight', '£5', '£10'],
['Standard GA Single', '£10', '£30'],
['GA Light Twin', '£15', '£50'],
['2500kg - 3000kg', '£30', '£120'],
['3001kg - 4000kg', '£40', '£160'],
['4001kg - 5000kg', '£80', '£360'],
];
const businessCharges = [
['4001kg - 5000kg', '£200', '5 hours free, £15 after', '£80'],
['5001kg - 6000kg', '£220', '5 hours free, £15 after', '£80'],
['6001kg - 7000kg', '£240', '5 hours free, £15 after', '£80'],
['7001kg - 8000kg', '£265', '4 hours free, £15 after', '£80'],
['8001kg - 9000kg', '£290', '4 hours free, £20 after', '£100'],
['9001kg - 10000kg', '£330', '4 hours free, £20 after', '£100'],
['10001kg - 11000kg', '£360', '£20', '£120'],
['11001kg - 12000kg', '£390', '£20', '£120'],
['12001kg - 13000kg', '£420', '£25', '£140'],
['13001kg - 14000kg', '£450', '£25', '£140'],
['14001kg - 15000kg', '£480', '£25', '£140'],
['15001kg - 16000kg', '£560', '£30', '£180'],
['16001kg - 17000kg', '£640', '£30', '£180'],
['17001kg - 18000kg', '£740', '£30', '£180'],
];
const additionalCharges = [
['Handling charges', 'Up to £60, price on request', 'Includes marshalling, catering, hotel and transport organisation, and use of crew room if available.'],
['Out of hours', '£130', 'Per hour required.'],
['Passenger loading', '£5 per passenger', 'Aircraft up to 5000kg.'],
['Passenger loading', '£8 per passenger', 'Aircraft between 5001kg and 10000kg.'],
['Passenger loading', '£10 per passenger', 'Aircraft 10001kg and above.'],
['Porterage charges', 'On request', 'Assistance with baggage or cargo from or to the aircraft.'],
['Runway closure', '£50', 'At management discretion following incident or accident.'],
['Drones', '£25', 'Commercial drones need 2 days notice before flight and a permit.'],
];
---
<BaseLayout title="Fees and Charges" description="Swansea Airport landing, parking, handling, and related charges.">
<section class="container fees-page">
<p class="eyebrow">About</p>
<h1 class="section-title">Fees and Charges</h1>
<p class="section-copy">Prices are based on maximum take-off weight (MTOW) unless otherwise stated.</p>
<section class="fee-section">
<h2>General Aviation</h2>
<div class="fee-table-wrap">
<table class="fee-table">
<thead>
<tr>
<th scope="col">Type</th>
<th scope="col">Landing fee</th>
<th scope="col">Daytime parking</th>
<th scope="col">Overnight parking outside</th>
<th scope="col">Overnight parking hangar</th>
</tr>
</thead>
<tbody>
{gaCharges.map((row) => (
<tr>
{row.map((cell) => <td>{cell}</td>)}
</tr>
))}
</tbody>
</table>
</div>
</section>
<section class="fee-section">
<h2>Touch and Go's</h2>
<div class="fee-table-wrap compact">
<table class="fee-table">
<thead>
<tr>
<th scope="col">Type</th>
<th scope="col">Single</th>
<th scope="col">Unlimited</th>
</tr>
</thead>
<tbody>
{touchAndGoCharges.map((row) => (
<tr>
{row.map((cell) => <td>{cell}</td>)}
</tr>
))}
</tbody>
</table>
</div>
</section>
<section class="fee-section">
<h2>GA Notes</h2>
<ul>
<li>Hangarage parking is subject to availability.</li>
<li>Daytime parking is 0900-1700 local.</li>
<li>Overnight parking is 1700-0900 local.</li>
<li>GA aircraft departing after hours will not be subject to additional fees.</li>
<li>Handling is free of charge up to 2500kg.</li>
<li>Aircraft above 2500kg in the GA category may be charged up to £60 for handling.</li>
<li>Other prices are by request.</li>
</ul>
</section>
<section class="fee-section">
<h2>Business and Corporate Aviation</h2>
<div class="fee-table-wrap">
<table class="fee-table">
<thead>
<tr>
<th scope="col">MTOW</th>
<th scope="col">Landing fee</th>
<th scope="col">Daytime parking</th>
<th scope="col">Overnight parking</th>
</tr>
</thead>
<tbody>
{businessCharges.map((row) => (
<tr>
{row.map((cell) => <td>{cell}</td>)}
</tr>
))}
</tbody>
</table>
</div>
<ul>
<li>Hangarage parking is subject to availability.</li>
<li>Daytime parking is 0900-1700 local.</li>
<li>Overnight parking is 1700-0900 local.</li>
<li>Aircraft departing out of hours will be charged £130 per hour.</li>
<li>Aircraft may be charged for handling services.</li>
</ul>
</section>
<section class="fee-section">
<h2>Handling and Additional Charges</h2>
<div class="fee-table-wrap">
<table class="fee-table">
<thead>
<tr>
<th scope="col">Charge</th>
<th scope="col">Price</th>
<th scope="col">Notes</th>
</tr>
</thead>
<tbody>
{additionalCharges.map((row) => (
<tr>
{row.map((cell) => <td>{cell}</td>)}
</tr>
))}
</tbody>
</table>
</div>
</section>
</section>
</BaseLayout>
<style>
.fees-page {
display: grid;
gap: 1.25rem;
}
.fee-section {
padding-block: 0.6rem;
}
.fee-section h2 {
margin: 0 0 0.8rem;
font-family: 'Manrope', system-ui, sans-serif;
font-weight: 800;
}
.fee-table-wrap {
width: 100%;
overflow-x: auto;
margin-block: 0.75rem 1rem;
}
.fee-table {
width: 100%;
min-width: 44rem;
border-collapse: collapse;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
overflow: hidden;
background: rgba(255, 255, 255, 0.86);
box-shadow: 0 12px 24px rgba(16, 34, 51, 0.08);
}
.fee-table-wrap.compact .fee-table {
min-width: 28rem;
}
.fee-table thead {
background: linear-gradient(180deg, rgba(11, 79, 122, 0.14), rgba(29, 118, 184, 0.09));
}
.fee-table th,
.fee-table td {
padding: 0.72rem 0.85rem;
border-bottom: 1px solid var(--line);
text-align: left;
vertical-align: top;
}
.fee-table th {
color: var(--brand);
font-size: 0.82rem;
font-weight: 800;
letter-spacing: 0.04em;
text-transform: uppercase;
white-space: nowrap;
}
.fee-table tbody tr:nth-child(even) {
background: rgba(255, 255, 255, 0.62);
}
.fee-table tbody tr:last-child td {
border-bottom: 0;
}
.fee-section ul {
margin-top: 0.6rem;
}
</style>
+4
View File
@@ -20,6 +20,10 @@ import BaseLayout from '../../layouts/BaseLayout.astro';
<h3><a href="/about/drones/">Drones</a></h3>
<p>Guidance and local operating expectations for drone use near the airport.</p>
</article>
<article class="card">
<h3><a href="/about/fees-and-charges/">Fees and Charges</a></h3>
<p>Landing, parking, handling, passenger loading, drone, and related operational charges.</p>
</article>
<article class="card">
<h3><a href="/about/noise/">Noise</a></h3>
<p>Information about noise awareness, reporting, and community engagement.</p>
+10
View File
@@ -0,0 +1,10 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
import DroneFlightRequestForm from '../components/DroneFlightRequestForm.astro';
---
<BaseLayout title="Drone Flight Request" description="Request a drone flight within the Swansea Airport FRZ.">
<div class="container prose">
<DroneFlightRequestForm />
</div>
</BaseLayout>
+1 -1
View File
@@ -66,7 +66,7 @@ const businessPromos = [
</p>
<div class="cta-row">
<a class="button primary" href="/pilot-info/">Pilot Info</a>
<a class="button secondary" href="/procedures-safety-noise-abatement/">Procedures and safety</a>
<a class="button primary" href="/ppr/">Request PPR</a>
</div>
</div>
</div>
+61 -7
View File
@@ -24,13 +24,72 @@ const fuelPrices = await getFuelPrices();
<BaseLayout title="Pilot Info" description="Essential information for pilots planning a visit to Swansea Airport.">
<div class="container prose">
<p class="eyebrow">Pilot info</p>
<h1 class="section-title">Operating hours</h1>
<p><strong>Airport open:</strong> Monday to Sunday, 0900-1800 local time.</p>
<p><strong>Licensed with fire cover:</strong> Friday, Saturday, and Sunday only. RFFS Cat A1.</p>
<h1 class="section-title">Communications</h1>
<p>
Swansea Airport operates a Air / Ground Communication Service, callsign Swansea Radio on 119.705. This service is staffed by volunteers and may not be operational at all times.
Swansea Airport operates a Air / Ground Communication Service, callsign SWANSEA RADIO on 119.705. This service is staffed by volunteers and may not be operational at all times.
</p>
<p>
When the Tower is unavailable, this will be NOTAMed and blind calls to Swansea Traffic shall be made on the Air / Ground frequency
When the Tower is unavailable, this will be NOTAMed and blind calls to Swansea Traffic shall be made on the Air / Ground frequency.
</p>
<p>Please submit your PPR request before flying into Swansea.</p>
<center><a class="button primary" href="/ppr/">Request PPR</a></center>
<h2>Procedures</h2>
<p>
<img src="/images/SDMAP.png" alt="Swansea Airport procedures map" loading="lazy" />
</p>
<h3>Arrivals</h3>
<p>
Make your initial arrival call 10 NM from the ATZ. From the east, this is above Port Talbot.
From the north, this is above Ammanford/Tycroes. From the west, this is just off the Gower
coast approaching Rhossili. From the south, this is over the sea abeam Porthcawl.
</p>
<p>
The Air/Ground service will advise if parachuting is in progress, as well as providing the
usual joining information. If parachuting is in progress, <strong>no overhead joins are permitted</strong>
and there is to be <strong>no dead-side flight within the ATZ</strong>.
</p>
<p>All joins are to be downwind or base leg joins only.</p>
<p>
When already on frequency, parachuting will be notified by the Air/Ground service using the
following message:
</p>
<blockquote>
ALL STATIONS, THIS IS SWANSEA RADIO. PARACHUTING HAS COMMENCED ON THE NORTHERN/SOUTHERN PLA.
NO OVERHEAD JOINS, NO DEAD SIDE FLIGHT.
</blockquote>
<p>
When this is called, <strong>no overhead joins or dead-side flight is permitted in the ATZ</strong>.
</p>
<h3>Departures</h3>
<p>
<strong>All helicopters are to request start.</strong> This is to protect parachuting operations.
If parachuting descent is already taking place, pilots will be advised to start only after the
parachuting descent has completed.
</p>
<p>When parachuting is in progress, all departing aircraft should:</p>
<ul>
<li>Climb straight ahead until clear of the ATZ.</li>
<li>Climb on the crosswind until clear of the ATZ.</li>
</ul>
<p>
<strong>Helicopters are to adhere to the circuit patterns when parachuting is taking place.</strong>
</p>
<p>
<strong>
Be advised that these procedures form part of the rules of the aerodrome. Permission to use
the aerodrome will be revoked if they are not adhered to.
</strong>
</p>
<h2>Runway Information</h2>
<div class="runway-table-wrap">
@@ -64,11 +123,6 @@ When the Tower is unavailable, this will be NOTAMed and blind calls to Swansea T
</ul>
)}
<p>Circuits at 1000ft agl. Overhead joins and/or dead-side flying not permitted when Parachuting is active.
</p>
<FuelPricesWidget fuelPrices={fuelPrices} />
+10
View File
@@ -0,0 +1,10 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
import PprRequestForm from '../components/PprRequestForm.astro';
---
<BaseLayout title="Request PPR" description="Submit a prior permission request for flights into Swansea Airport.">
<div class="container prose">
<PprRequestForm />
</div>
</BaseLayout>
@@ -1,29 +0,0 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
---
<BaseLayout title="Procedures, Safety and Noise Abatement" description="Operational procedures, safety notes, and noise-sensitive guidance.">
<div class="container prose">
<p class="eyebrow">Operations</p>
<h1 class="section-title">Procedures, safety, and noise abatement</h1>
<p>
This page is intentionally text-led and easy to scan. It is controlled by Astro so the structure stays stable even as the content evolves.
</p>
<h2>Safety priorities</h2>
<div class="cards-grid">
<article class="card">
<h3>Brief before flight</h3>
<p>Surface the checklist items pilots need most, without burying them under visual clutter.</p>
</article>
<article class="card">
<h3>Check current notices</h3>
<p>Operational notices should be reviewed before taxi, because the homepage is fed by the same notices collection.</p>
</article>
<article class="card">
<h3>Respect local noise guidance</h3>
<p>Noise abatement text can be expanded in Directus while the page structure stays fixed in code.</p>
</article>
</div>
</div>
</BaseLayout>