Preserve source identity and history references through layer state

This commit is contained in:
Sameh Khamis committed 2026-09-13 09:06:13 -07:00
1 parent 80c5a09f15
commit 73135a7ae4
8 files changed
+92 -37

No files matched your search

+2 -1
View File
@@ -10,7 +10,8 @@ retain separate heading/course and sea-surface datum. History is a best-effort
addition to the locally accumulated trail, never a promise of complete coverage. addition to the locally accumulated trail, never a promise of complete coverage.
Snapshot coverage, completeness and freshness are separate fields. A partially Snapshot coverage, completeness and freshness are separate fields. A partially
admitted aircraft snapshot cannot evict absent contacts. An invalid nonempty admitted aircraft snapshot retains absent contacts for up to five minutes before
the usual missed-poll eviction. Unknown snapshot times remain unknown in stats. An invalid nonempty
snapshot retains the previous display. Empty vessel refreshes retain the existing snapshot retains the previous display. Empty vessel refreshes retain the existing
first-connect grace and warm-data behavior. Known source failures have bounded first-connect grace and warm-data behavior. Known source failures have bounded
messages; arbitrary HTTP response bodies are not surfaced as diagnostics. messages; arbitrary HTTP response bodies are not surfaced as diagnostics.
+4 -2
View File
@@ -1,6 +1,5 @@
import { createAisStreamSource } from '../sources/live/standalone.js'; import { createAisStreamSource } from '../sources/live/standalone.js';
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
let _source = createAisStreamSource({ apiUrl: import.meta.env?.VITE_AIS_LIVE_API_URL || '/api/ais-live' });
import { import {
registerEntityContext, registerEntityContext,
selectEntityContext, selectEntityContext,
@@ -48,6 +47,8 @@ import {
import { requestWorldFocus } from '../worldFocus.js'; import { requestWorldFocus } from '../worldFocus.js';
import { holdContinuousRender, releaseContinuousRender } from '../renderGovernor.js'; import { holdContinuousRender, releaseContinuousRender } from '../renderGovernor.js';
let _source = createAisStreamSource({ apiUrl: import.meta.env?.VITE_AIS_LIVE_API_URL || '/api/ais-live' });
const FOCUS_EVIDENCE_DEV = import.meta.env?.DEV === true; const FOCUS_EVIDENCE_DEV = import.meta.env?.DEV === true;
/** Camera pose signature at the last vessel rotation pass. */ /** Camera pose signature at the last vessel rotation pass. */
@@ -349,6 +350,7 @@ const aisLiveVesselsLayer = {
if (state.viewer) throw new Error('Configure the source before layer initialization'); if (state.viewer) throw new Error('Configure the source before layer initialization');
if (typeof source?.getSnapshot !== 'function') throw new TypeError('A snapshot source is required'); if (typeof source?.getSnapshot !== 'function') throw new TypeError('A snapshot source is required');
_source = source; _source = source;
this.source = source.label || this.source;
}, },
init(viewer) { init(viewer) {
@@ -1639,7 +1641,7 @@ function startSelectedVesselTrail(record) {
state.trail = createTrail(state.viewer, { color: TRAIL_COLOR, width: 2.5 }); state.trail = createTrail(state.viewer, { color: TRAIL_COLOR, width: 2.5 });
} }
if (state.trail) state.trail.setPositions(state.trailPositions); if (state.trail) state.trail.setPositions(state.trailPositions);
backfillVesselTrail(record.mmsi, state.trailBackfillToken); backfillVesselTrail(record.mmsi, state.trailBackfillToken, record.reference);
} }
/** /**
+25
View File
@@ -1641,3 +1641,28 @@ test('a vessel analyst record carries the MMSI the tracker keys on', () => {
assert.equal(nameless.id, '366999124'); assert.equal(nameless.id, '366999124');
assert.equal(nameless.mmsi, '366999124'); assert.equal(nameless.mmsi, '366999124');
}); });
test('vessel selection passes the opaque source reference to optional history', async () => {
const { createAisStreamSource } = await import('../sources/live/standalone.js');
_setVesselStateForTest({ enabled: false });
const requests = [];
aisLiveVesselsLayer.setSource({
label: 'Test vessel source',
getSnapshot: async () => ({ records: [] }),
getTrack: async (reference, options) => { requests.push({ reference, options }); return { records: [] }; },
});
const harness = installWireHarness(undefined, { selectedRecord: null, trailMmsi: null });
try {
harness.record.reference = 'opaque:test-reference';
assert.equal(aisLiveVesselsLayer.selectById(harness.record.mmsi), true);
await new Promise(resolve => setImmediate(resolve));
assert.equal(requests.length, 1);
assert.equal(requests[0].reference, 'opaque:test-reference');
assert.ok(requests[0].options.signal instanceof AbortSignal);
assert.equal(aisLiveVesselsLayer.source, 'Test vessel source');
} finally {
harness.cleanup();
_setVesselStateForTest({ enabled: false });
aisLiveVesselsLayer.setSource(createAisStreamSource());
}
});
+24 -23
View File
@@ -21,7 +21,6 @@ import { createOpenSkySource } from '../sources/live/standalone.js';
* looking at (owner decision 2026-07-02). * looking at (owner decision 2026-07-02).
*/ */
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
let _source = createOpenSkySource();
import { aircraftIncludedInNearby } from './aircraftNearbyPolicy.js'; import { aircraftIncludedInNearby } from './aircraftNearbyPolicy.js';
import { registerPickOwner, unregisterPickOwner, isOwnedByOtherLayer, resolvePickId } from './pickRegistry.js'; import { registerPickOwner, unregisterPickOwner, isOwnedByOtherLayer, resolvePickId } from './pickRegistry.js';
import { import {
@@ -97,6 +96,8 @@ import {
import { CONTACT_MATCH_TIER, contactMatchWins, rankContactMatch } from './contactMatch.js'; import { CONTACT_MATCH_TIER, contactMatchWins, rankContactMatch } from './contactMatch.js';
import { holdContinuousRender, releaseContinuousRender } from '../renderGovernor.js'; import { holdContinuousRender, releaseContinuousRender } from '../renderGovernor.js';
let _source = createOpenSkySource();
const FOCUS_EVIDENCE_DEV = import.meta.env?.DEV === true; const FOCUS_EVIDENCE_DEV = import.meta.env?.DEV === true;
/** Amber tint for known-military aircraft rendered by this layer (matches the military layer's icon color). */ /** Amber tint for known-military aircraft rendered by this layer (matches the military layer's icon color). */
@@ -340,7 +341,7 @@ let _lastTrackingRefreshOutcome = {
epoch: 0, epoch: 0,
status: 'unavailable', status: 'unavailable',
ids: new Set(), ids: new Set(),
source: 'OpenSky Network', source: _lastSource,
coverage: null, coverage: null,
}; };
/** @type {Cesium.Entity|null} Entity used for camera tracking */ /** @type {Cesium.Entity|null} Entity used for camera tracking */
@@ -412,7 +413,7 @@ function _contextSubjectMetadata(icao24) {
id: icao24, id: icao24,
layerId: 'flights', layerId: 'flights',
layerName: 'Live Flights', layerName: 'Live Flights',
source: 'OpenSky Network', source: _lastSource,
label: _contactLabel(icao24, _flightData.get(icao24)), label: _contactLabel(icao24, _flightData.get(icao24)),
latitude: described.latitude, latitude: described.latitude,
longitude: described.longitude, longitude: described.longitude,
@@ -996,13 +997,6 @@ let _drReconcileValid = false;
/** @type {string|null} icao the reconciliation state currently belongs to */ /** @type {string|null} icao the reconciliation state currently belongs to */
let _drReconcileIcao = null; let _drReconcileIcao = null;
/**
* Normalize a value to a trimmed lowercase string.
* @param {*} value - Any value (typically a header string or null).
* @returns {string} Lowercase trimmed string, or '' if falsy.
*/
/** /**
* Normalize a value to a trimmed string. A whitespace-only field (" ") is * Normalize a value to a trimmed string. A whitespace-only field (" ") is
* truthy, so every label chain must trim FIRST and then fall through. * truthy, so every label chain must trim FIRST and then fall through.
@@ -2991,7 +2985,7 @@ function _startTrail(icao24) {
async function _backfillTrail(icao24, token, oldestFixEpochSec) { async function _backfillTrail(icao24, token, oldestFixEpochSec) {
let path = null; let path = null;
try { try {
const track = await _source.getTrack?.(icao24, { signal: AbortSignal.timeout(8000) }); const track = await _source.getTrack?.(_flightData.get(icao24)?.sourceReference ?? icao24, { signal: AbortSignal.timeout(8000) });
path = track?.records ?? null; path = track?.records ?? null;
} catch { } catch {
return; // silent fallback to the accumulated trail return; // silent fallback to the accumulated trail
@@ -3822,26 +3816,28 @@ const flightsLayer = {
id: 'flights', id: 'flights',
name: 'Live Flights', name: 'Live Flights',
icon: '✈️', icon: '✈️',
source: 'OpenSky Network', source: _lastSource,
// Browser-harness seam: isolates synthetic display-floor scenarios without // Browser-harness seam: isolates synthetic display-floor scenarios without
// changing any production lifecycle or cache policy. // changing any production lifecycle or cache policy.
_clearDisplayFloorStateForTest, _clearDisplayFloorStateForTest,
/** @type {number} Polling interval (ms) between update() calls */ /** @type {number} Polling interval (ms) between update() calls */
updateInterval: 30000, updateInterval: 30000,
/** Configure the source before initialization; an active layer keeps its owner. */
setSource(source) {
if (_viewer) throw new Error('Configure the source before layer initialization');
if (typeof source?.getSnapshot !== 'function') throw new TypeError('A snapshot source is required');
_source = source;
_lastSource = source.label || _lastSource;
this.source = _lastSource;
},
/** /**
* Initialize the flights layer. * Initialize the flights layer.
* Creates the BillboardCollection, resets all state, and installs the * Creates the BillboardCollection, resets all state, and installs the
* click-to-track handler on the scene canvas. * click-to-track handler on the scene canvas.
* @param {Cesium.Viewer} viewer - The CesiumJS viewer instance. * @param {Cesium.Viewer} viewer - The CesiumJS viewer instance.
*/ */
/** Configure the source before initialization; an active layer keeps its owner. */
setSource(source) {
if (_viewer) throw new Error('Configure the source before layer initialization');
if (typeof source?.getSnapshot !== 'function') throw new TypeError('A snapshot source is required');
_source = source;
},
init(viewer) { init(viewer) {
clearFocusTarget('flights'); clearFocusTarget('flights');
_focusEvidenceIds.clear(); _focusEvidenceIds.clear();
@@ -3875,7 +3871,7 @@ const flightsLayer = {
_retryAt = 0; _retryAt = 0;
_lastError = null; _lastError = null;
_lastStatus = null; _lastStatus = null;
_lastSource = 'OpenSky Network'; _lastSource = _source.label || 'OpenSky Network';
_lastCoverage = 'worldwide upstream snapshot'; _lastCoverage = 'worldwide upstream snapshot';
_trackedIcao = null; _trackedIcao = null;
_resetTrackedSelectionState(); _resetTrackedSelectionState();
@@ -4029,7 +4025,7 @@ const flightsLayer = {
const usableStates = snapshot.records; const usableStates = snapshot.records;
const sourceEpochMs = snapshot.observedAtMs; const sourceEpochMs = snapshot.observedAtMs;
const sourceAgeMs = snapshot.ageMs; const sourceAgeMs = snapshot.ageMs;
const sourceStale = snapshot.stale; const sourceStale = snapshot.stale || snapshot.freshness === 'unknown';
_backoff = sourceStale; _backoff = sourceStale;
_retryAt = 0; _retryAt = 0;
_lastError = sourceStale _lastError = sourceStale
@@ -4219,6 +4215,8 @@ const flightsLayer = {
// Store flight metadata for click-to-track labels // Store flight metadata for click-to-track labels
const cat = stickyNumber(category, prevMeta?.category, null); const cat = stickyNumber(category, prevMeta?.category, null);
const meta = { const meta = {
sourceReference: observation.reference,
observedReceiptMs: Date.now(),
callsign: stickyText(callsign, prevMeta?.callsign), callsign: stickyText(callsign, prevMeta?.callsign),
altitude: alt, altitude: alt,
// geoAltitudeM/renderAltitudeM are ADDITIVE fields alongside the // geoAltitudeM/renderAltitudeM are ADDITIVE fields alongside the
@@ -4398,7 +4396,9 @@ const flightsLayer = {
// LANDED_MISSING_POLL_LIMIT — their disappearance means "landed", not a // LANDED_MISSING_POLL_LIMIT — their disappearance means "landed", not a
// feed gap, and the full grace left phantom planes parked at airports. // feed gap, and the full grace left phantom planes parked at airports.
for (const [icao24, bb] of _billboards) { for (const [icao24, bb] of _billboards) {
if (currentIcaos.has(icao24) || !snapshot.complete) continue; if (currentIcaos.has(icao24)) continue;
// Partial admissions do not prove absence, but stale retention is bounded.
if (!snapshot.complete && Date.now() - (_flightData.get(icao24)?.observedReceiptMs ?? 0) < 300000) continue;
const misses = (_missingPolls.get(icao24) || 0) + 1; const misses = (_missingPolls.get(icao24) || 0) + 1;
const limit = _likelyLanded(icao24) ? LANDED_MISSING_POLL_LIMIT : MISSING_POLL_LIMIT; const limit = _likelyLanded(icao24) ? LANDED_MISSING_POLL_LIMIT : MISSING_POLL_LIMIT;
if (misses < limit) { if (misses < limit) {
@@ -4477,7 +4477,7 @@ const flightsLayer = {
_count = _billboards.size; _count = _billboards.size;
// Freshness belongs to the source snapshot, not the moment this browser // Freshness belongs to the source snapshot, not the moment this browser
// received a cached 200 response. // received a cached 200 response.
_lastUpdate = sourceEpochMs ?? Date.now(); _lastUpdate = sourceEpochMs;
_lastTrackingRefreshOutcome = { _lastTrackingRefreshOutcome = {
epoch: trackingRefreshEpoch, epoch: trackingRefreshEpoch,
status: 'accepted', status: 'accepted',
@@ -4496,6 +4496,7 @@ const flightsLayer = {
_backoff = true; _backoff = true;
_retryAt = Date.now() + (e?.retryAfterMs ?? ERROR_BACKOFF_INTERVAL); _retryAt = Date.now() + (e?.retryAfterMs ?? ERROR_BACKOFF_INTERVAL);
_lastStatus = e?.status ?? null; _lastStatus = e?.status ?? null;
if (e?.source) { _lastSource = e.source; this.source = _lastSource; }
_lastError = e?.name === 'LiveSourceError' ? e.message : 'Live data unavailable'; _lastError = e?.name === 'LiveSourceError' ? e.message : 'Live data unavailable';
} finally { } finally {
_activeUpdateControllers.delete(resourceController); _activeUpdateControllers.delete(resourceController);
+17 -9
View File
@@ -1,6 +1,5 @@
import { createAdsbLolSource } from '../sources/live/standalone.js'; import { createAdsbLolSource } from '../sources/live/standalone.js';
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
let _source = createAdsbLolSource();
import { aircraftIncludedInNearby } from './aircraftNearbyPolicy.js'; import { aircraftIncludedInNearby } from './aircraftNearbyPolicy.js';
import { registerPickOwner, unregisterPickOwner, isOwnedByOtherLayer, resolvePickId } from './pickRegistry.js'; import { registerPickOwner, unregisterPickOwner, isOwnedByOtherLayer, resolvePickId } from './pickRegistry.js';
import { registerSpriteCollection, restoreSpriteOrder } from './spriteOrder.js'; import { registerSpriteCollection, restoreSpriteOrder } from './spriteOrder.js';
@@ -64,6 +63,8 @@ import {
import { CONTACT_MATCH_TIER, contactMatchWins, rankContactMatch } from './contactMatch.js'; import { CONTACT_MATCH_TIER, contactMatchWins, rankContactMatch } from './contactMatch.js';
import { holdContinuousRender, releaseContinuousRender } from '../renderGovernor.js'; import { holdContinuousRender, releaseContinuousRender } from '../renderGovernor.js';
let _source = createAdsbLolSource();
/** /**
* @module militaryFlights * @module militaryFlights
* @description Real-time military flight tracking layer powered by the adsb.lol API. * @description Real-time military flight tracking layer powered by the adsb.lol API.
@@ -2195,7 +2196,7 @@ function _startTrail(icao24) {
async function _backfillTrail(icao24, token, oldestFixEpochSec) { async function _backfillTrail(icao24, token, oldestFixEpochSec) {
let trace = null; let trace = null;
try { try {
const track = await _source.getTrack?.(icao24, { signal: AbortSignal.timeout(8000) }); const track = await _source.getTrack?.(_flightData.get(icao24)?.sourceReference ?? icao24, { signal: AbortSignal.timeout(8000) });
trace = track?.records ?? null; trace = track?.records ?? null;
} catch { } catch {
return; // silent fallback to the accumulated trail return; // silent fallback to the accumulated trail
@@ -2591,18 +2592,20 @@ const militaryFlightsLayer = {
/** @type {number} Polling interval in ms between API fetches */ /** @type {number} Polling interval in ms between API fetches */
updateInterval: 15000, updateInterval: 15000,
/**
* Initialize the layer: create the billboard collection, reset all state,
* and install the click-to-track handler.
* @param {Cesium.Viewer} viewer - The Cesium viewer instance
*/
/** Configure the source before initialization; an active layer keeps its owner. */ /** Configure the source before initialization; an active layer keeps its owner. */
setSource(source) { setSource(source) {
if (_viewer) throw new Error('Configure the source before layer initialization'); if (_viewer) throw new Error('Configure the source before layer initialization');
if (typeof source?.getSnapshot !== 'function') throw new TypeError('A snapshot source is required'); if (typeof source?.getSnapshot !== 'function') throw new TypeError('A snapshot source is required');
_source = source; _source = source;
_lastSource = source.label || _lastSource;
this.source = _lastSource;
}, },
/**
* Initialize the layer: create the billboard collection, reset all state,
* and install the click-to-track handler.
* @param {Cesium.Viewer} viewer - The Cesium viewer instance
*/
init(viewer) { init(viewer) {
clearFocusTarget('militaryFlights'); clearFocusTarget('militaryFlights');
_viewer = viewer; _viewer = viewer;
@@ -2782,7 +2785,7 @@ const militaryFlightsLayer = {
_lastSource = snapshot.source; _lastSource = snapshot.source;
militaryFlightsLayer.source = _lastSource; militaryFlightsLayer.source = _lastSource;
const usableAircraft = snapshot.records; const usableAircraft = snapshot.records;
_backoff = snapshot.stale; _backoff = snapshot.stale || snapshot.freshness === 'unknown';
_retryAt = 0; _retryAt = 0;
_lastError = snapshot.freshness === 'unknown' ? 'Source snapshot time unavailable' : null; _lastError = snapshot.freshness === 'unknown' ? 'Source snapshot time unavailable' : null;
const currentIcaos = new Set(); const currentIcaos = new Set();
@@ -2900,6 +2903,8 @@ const militaryFlightsLayer = {
// last-known-good (bounded by the layer's eviction, which deletes the entry). // last-known-good (bounded by the layer's eviction, which deletes the entry).
const stickyType = stickyText(type, prevMeta?.type); const stickyType = stickyText(type, prevMeta?.type);
const meta = { const meta = {
sourceReference: aircraft.reference,
observedReceiptMs: Date.now(),
callsign: stickyText(callsign, prevMeta?.callsign), callsign: stickyText(callsign, prevMeta?.callsign),
type: stickyType, type: stickyType,
// Type outranks category automatically inside classifyAircraft. // Type outranks category automatically inside classifyAircraft.
@@ -3094,7 +3099,9 @@ const militaryFlightsLayer = {
// LANDED_MISSING_POLL_LIMIT — their disappearance means "landed", not a // LANDED_MISSING_POLL_LIMIT — their disappearance means "landed", not a
// feed gap, and the full grace left phantom planes parked at airports. // feed gap, and the full grace left phantom planes parked at airports.
for (const [icao24, bb] of _billboards) { for (const [icao24, bb] of _billboards) {
if (currentIcaos.has(icao24) || !snapshot.complete) continue; if (currentIcaos.has(icao24)) continue;
// Partial admissions do not prove absence, but stale retention is bounded.
if (!snapshot.complete && Date.now() - (_flightData.get(icao24)?.observedReceiptMs ?? 0) < 300000) continue;
const misses = (_missingPolls.get(icao24) || 0) + 1; const misses = (_missingPolls.get(icao24) || 0) + 1;
const limit = _likelyLanded(icao24) ? LANDED_MISSING_POLL_LIMIT : MISSING_POLL_LIMIT; const limit = _likelyLanded(icao24) ? LANDED_MISSING_POLL_LIMIT : MISSING_POLL_LIMIT;
if (misses < limit) { if (misses < limit) {
@@ -3155,6 +3162,7 @@ const militaryFlightsLayer = {
_backoff = true; _backoff = true;
_retryAt = Date.now() + (e?.retryAfterMs ?? ERROR_BACKOFF_INTERVAL); _retryAt = Date.now() + (e?.retryAfterMs ?? ERROR_BACKOFF_INTERVAL);
_lastStatus = e?.status ?? null; _lastStatus = e?.status ?? null;
if (e?.source) { _lastSource = e.source; this.source = _lastSource; }
_lastError = e?.name === 'LiveSourceError' ? e.message : 'Live data unavailable'; _lastError = e?.name === 'LiveSourceError' ? e.message : 'Live data unavailable';
} finally { } finally {
_activeUpdateControllers.delete(resourceController); _activeUpdateControllers.delete(resourceController);
+14
View File
@@ -247,3 +247,17 @@ test('AIS reports limited received coverage and keeps connection state separate
now, now,
); );
}); });
test('a malformed vessel row cannot prevent admission of valid positions', async () => {
const source = createAisStreamSource({
fetchImpl: async () =>
response({
status: 'live',
rows: [null, { mmsi: '123456789', lat: 30, lon: -97 }],
}),
});
const snapshot = await source.getSnapshot();
assert.equal(snapshot.records.length, 1);
assert.equal(snapshot.rejectedCount, 1);
assert.equal(snapshot.complete, false);
});
+3
View File
@@ -54,6 +54,7 @@ export function createOpenSkySource({
now = () => Date.now(), now = () => Date.now(),
} = {}) { } = {}) {
return { return {
label: 'OpenSky Network',
async getSnapshot(query = {}, { signal } = {}) { async getSnapshot(query = {}, { signal } = {}) {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (Number.isFinite(query.latitude) && Number.isFinite(query.longitude)) { if (Number.isFinite(query.latitude) && Number.isFinite(query.longitude)) {
@@ -111,6 +112,7 @@ export function createAdsbLolSource({
now = () => Date.now(), now = () => Date.now(),
} = {}) { } = {}) {
return { return {
label: 'adsb.lol',
async getSnapshot(_query = {}, { signal } = {}) { async getSnapshot(_query = {}, { signal } = {}) {
const { response, payload } = await readResponse( const { response, payload } = await readResponse(
fetchImpl, fetchImpl,
@@ -158,6 +160,7 @@ export function createAisStreamSource({
origin = () => globalThis.location?.origin || 'http://localhost', origin = () => globalThis.location?.origin || 'http://localhost',
} = {}) { } = {}) {
return { return {
label: 'AISStream',
async getSnapshot({ maxRows = 12000 } = {}, { signal } = {}) { async getSnapshot({ maxRows = 12000 } = {}, { signal } = {}) {
const url = new URL(apiUrl, origin()); const url = new URL(apiUrl, origin());
url.searchParams.set('maxRows', String(maxRows)); url.searchParams.set('maxRows', String(maxRows));
+3 -2
View File
@@ -40,7 +40,8 @@ export function vesselSnapshot(
const records = [], const records = [],
ids = new Set(); ids = new Set();
for (const row of rows) { for (const row of rows) {
const record = normalizeVesselObservation(row, referenceFor(row)); const record = normalizeVesselObservation(row);
if (record) record.reference = referenceFor(row);
if (record && !ids.has(record.id)) { if (record && !ids.has(record.id)) {
records.push(record); records.push(record);
ids.add(record.id); ids.add(record.id);
@@ -57,7 +58,7 @@ export function vesselSnapshot(
records, records,
source, source,
coverage, coverage,
complete: false, complete: records.length === rows.length && !payload?.refreshing,
rejectedCount: rows.length - records.length, rejectedCount: rows.length - records.length,
observedAtMs, observedAtMs,
freshness: payload?.refreshing freshness: payload?.refreshing