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
38 changes: 38 additions & 0 deletions examples/ag-ui/angular/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<!-- SPDX-License-Identifier: MIT -->
# AG-UI Itinerary example (Angular)

A trip-planner demo where the agent edits a live itinerary UI over the
[AG-UI](https://github.com/cacheplane/angular-agent-framework) transport. It has a
chat/panel layout and an **App mode** that swaps the panel for a full-bleed
Google Map cockpit.

## Google Maps (App mode)

App mode renders a Google Map, which needs a Maps JavaScript API key. The key is
read from the repo-root `.env` at build time (via `scripts/inject-env.mjs`, which
writes a gitignored `src/environments/generated-keys.local.ts`). Add to the root
`.env`:

```
GOOGLE_MAPS_API_KEY=your_api_key_here
```

Without a key, App mode's map stays disabled (the rest of the demo still works).

### Dark map theme (optional)

App-mode markers use Google's **Advanced Markers**, which require the map to have
a **Map ID** — and a map with a Map ID takes its styling from a **cloud-based map
style** (the inline JSON dark theme no longer applies). To get the dark theme:

1. In the [Google Cloud Console](https://console.cloud.google.com/google/maps-apis/studio/maps),
create a **vector Map ID** and a **dark map style**, and associate them.
2. Add the id to the root `.env`:

```
GOOGLE_MAPS_MAP_ID=your_map_id_here
```

Without `GOOGLE_MAPS_MAP_ID`, the demo falls back to Google's `DEMO_MAP_ID` and
renders a **light** map — markers and routes still work, only the dark styling is
absent.
4 changes: 3 additions & 1 deletion examples/ag-ui/angular/scripts/inject-env.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,15 @@

const env = { ...readDotEnv(), ...process.env };
const key = env.GOOGLE_MAPS_API_KEY ?? '';
const mapId = env.GOOGLE_MAPS_MAP_ID ?? '';

const targetPath = resolve(__dirname, '../src/environments/generated-keys.local.ts');
const contents = `// SPDX-License-Identifier: MIT
// AUTO-GENERATED by scripts/inject-env.mjs. Do not edit by hand.
export const GENERATED_KEYS = {
googleMaps: ${JSON.stringify(key)},
googleMapsMapId: ${JSON.stringify(mapId)},
} as const;
`;
writeFileSync(targetPath, contents);
console.log(`[inject-env] wrote generated-keys.local.ts (key length: ${key.length})`);
console.log(`[inject-env] wrote generated-keys.local.ts (key length: ${key.length}, mapId: ${mapId ? 'set' : 'unset'})`);

Check failure

Code scanning / CodeQL

Clear-text logging of sensitive information High

This logs sensitive data returned by
an access to GOOGLE_MAPS_API_KEY
as clear text.
2 changes: 1 addition & 1 deletion examples/ag-ui/angular/src/app/google-maps-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export class GoogleMapsLoader {
}

const script = doc.createElement('script');
script.src = `https://maps.googleapis.com/maps/api/js?key=${encodeURIComponent(key)}&libraries=geocoding`;
script.src = `https://maps.googleapis.com/maps/api/js?key=${encodeURIComponent(key)}&libraries=geocoding,marker`;
script.async = true;
script.setAttribute('data-google-maps', '');
script.addEventListener('load', () => this.loaded.set(true));
Expand Down
71 changes: 33 additions & 38 deletions examples/ag-ui/angular/src/app/map-canvas.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,32 +10,18 @@ import {
viewChild,
viewChildren,
} from '@angular/core';
import { GoogleMap, MapInfoWindow, MapMarker, MapPolyline } from '@angular/google-maps';
import { GoogleMap, MapInfoWindow, MapAdvancedMarker, MapPolyline } from '@angular/google-maps';
import { ItineraryStop, ItineraryStore } from './itinerary-store';
import { GoogleMapsLoader } from './google-maps-loader';

const DARK_STYLE: google.maps.MapTypeStyle[] = [
{ elementType: 'geometry', stylers: [{ color: '#1d2c4d' }] },
{ elementType: 'labels.text.fill', stylers: [{ color: '#8ec3b9' }] },
{ elementType: 'labels.text.stroke', stylers: [{ color: '#1a3646' }] },
{ featureType: 'administrative.country', elementType: 'geometry.stroke', stylers: [{ color: '#4b6878' }] },
{ featureType: 'landscape.man_made', elementType: 'geometry.stroke', stylers: [{ color: '#334e87' }] },
{ featureType: 'landscape.natural', elementType: 'geometry', stylers: [{ color: '#023e58' }] },
{ featureType: 'poi', elementType: 'geometry', stylers: [{ color: '#283d6a' }] },
{ featureType: 'road', elementType: 'geometry', stylers: [{ color: '#304a7d' }] },
{ featureType: 'road', elementType: 'labels.text.fill', stylers: [{ color: '#98a5be' }] },
{ featureType: 'transit', elementType: 'geometry', stylers: [{ color: '#283d6a' }] },
{ featureType: 'water', elementType: 'geometry', stylers: [{ color: '#0e1626' }] },
{ featureType: 'water', elementType: 'labels.text.fill', stylers: [{ color: '#4e6d70' }] },
];
import { environment } from '../environments/environment';

const DAY_COLORS = ['#ef4444', '#3b82f6', '#10b981', '#f59e0b', '#8b5cf6', '#ec4899', '#06b6d4', '#84cc16'];
const PARIS_CENTER: google.maps.LatLngLiteral = { lat: 48.8566, lng: 2.3522 };

@Component({
selector: 'app-map-canvas',
standalone: true,
imports: [GoogleMap, MapInfoWindow, MapMarker, MapPolyline],
imports: [GoogleMap, MapInfoWindow, MapAdvancedMarker, MapPolyline],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<!-- Render <google-map> ONLY after the Maps API has loaded. Its constructor
Expand All @@ -49,12 +35,13 @@ const PARIS_CENTER: google.maps.LatLngLiteral = { lat: 48.8566, lng: 2.3522 };
[zoom]="zoom()"
[options]="mapOptions"
>
@for (s of stopsWithCoords(); track s.id) {
<map-marker
@for (m of markerViews(); track m.id) {
<map-advanced-marker
#marker
[position]="{ lat: s.lat!, lng: s.lng! }"
[options]="markerOptions(s)"
(mapClick)="onMarkerClick(s)"
[position]="m.position"
[content]="m.content"
[title]="m.stop.place"
(mapClick)="onMarkerClick(m.stop)"
/>
}
@for (line of polylines(); track line.day) {
Expand Down Expand Up @@ -92,20 +79,36 @@ export class MapCanvasComponent {
protected readonly loader = inject(GoogleMapsLoader);
protected readonly center = signal<google.maps.LatLngLiteral>(PARIS_CENTER);
protected readonly zoom = signal<number>(12);
// A mapId is REQUIRED for advanced markers; a mapId map ignores inline JSON
// `styles`, so the dark theme is a cloud-based map style tied to this id.
// DEMO_MAP_ID lets a fresh clone run (light map) with no Console setup.
protected readonly mapId = environment.googleMapsMapId || 'DEMO_MAP_ID';
protected readonly mapOptions: google.maps.MapOptions = {
styles: DARK_STYLE,
mapId: this.mapId,
disableDefaultUI: true,
zoomControl: true,
clickableIcons: false,
};

private readonly infoWindow = viewChild(MapInfoWindow);
private readonly markers = viewChildren(MapMarker);
private readonly markers = viewChildren(MapAdvancedMarker);

protected readonly stopsWithCoords = computed(() =>
this.store.stops().filter((s) => s.lat != null && s.lng != null),
);

/** One marker view per coord'd stop. The pin <div> is built here (not in a
* template method) so it is recreated only when stops change — not on every
* change-detection pass (e.g. focus pans), which would cause flicker. */
protected readonly markerViews = computed(() =>
this.stopsWithCoords().map((s) => ({
id: s.id,
stop: s,
position: { lat: s.lat!, lng: s.lng! },
content: this.makePin(s.day),
})),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

makePin allocates a new HTMLElement for every stop on every execution of this computed. stopsWithCoords() recomputes whenever any stop mutates — including text-only changes (agent renames a place, adds a note) — so all pin nodes are silently replaced even when no day or position changed. The old elements handed to the Maps API are detached and GC'd, so it's not a correctness bug, but it's more aggressive DOM churn than the @for track m.id rhythm implies.

An easy guard: key the element by (s.id, s.day) with a Map cache so pins are only rebuilt when the day (i.e., colour) actually changes:

Suggested change
})),
protected readonly markerViews = computed(() => {
const prev = this._pinCache;
const next = new Map<string, HTMLElement>();
return this.stopsWithCoords().map((s) => {
const cacheKey = `${s.id}:${s.day}`;
const content = prev.get(cacheKey) ?? this.makePin(s.day);
next.set(cacheKey, content);
this._pinCache = next;
return { id: s.id, stop: s, position: { lat: s.lat!, lng: s.lng! }, content };
});
});
private _pinCache = new Map<string, HTMLElement>();

Or, if you prefer a lighter touch, just leave a comment clarifying that "stops change" includes text edits, so readers aren't surprised.

);

protected readonly focused = computed(
() => this.store.stops().find((s) => s.id === this.store.focusedStopId()) ?? null,
);
Expand Down Expand Up @@ -146,20 +149,12 @@ export class MapCanvasComponent {
.map(([day, stops]) => ({ day, path: stops.map((s) => ({ lat: s.lat!, lng: s.lng! })) }));
});

protected markerOptions(s: ItineraryStop): google.maps.MarkerOptions {
return {
icon: {
// Numeric literal for google.maps.SymbolPath.CIRCLE (=0) — avoids an
// eager google.* value read (defense-in-depth alongside the loader gate).
path: 0,
fillColor: DAY_COLORS[(s.day - 1) % DAY_COLORS.length],
fillOpacity: 1,
strokeColor: '#fff',
strokeWeight: 2,
scale: 8,
},
title: s.place,
};
private makePin(day: number): HTMLElement {
const el = document.createElement('div');
el.style.cssText =
'width:16px;height:16px;border-radius:50%;border:2px solid #fff;' +
`box-shadow:0 1px 3px rgba(0,0,0,.4);background:${DAY_COLORS[(day - 1) % DAY_COLORS.length]};`;
return el;
}

protected polylineOptions(day: number): google.maps.PolylineOptions {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@ export const environment = {
telemetry: { enabled: false, sampleRate: 1 },
license: undefined as string | undefined,
googleMapsApiKey: GENERATED_KEYS.googleMaps,
googleMapsMapId: GENERATED_KEYS.googleMapsMapId,
};
1 change: 1 addition & 0 deletions examples/ag-ui/angular/src/environments/environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@ export const environment = {
telemetry: { enabled: false, sampleRate: 1 },
license: undefined as string | undefined,
googleMapsApiKey: GENERATED_KEYS.googleMaps,
googleMapsMapId: GENERATED_KEYS.googleMapsMapId,
};
1 change: 1 addition & 0 deletions examples/ag-ui/angular/src/environments/generated-keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@
// Ships empty (CI has no key); local/preview builds get the real value.
export const GENERATED_KEYS = {
googleMaps: '',
googleMapsMapId: '',
} as const;
Loading