Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Map country areas #484

Open
wants to merge 4 commits into
base: develop
Choose a base branch
from
Open
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
127 changes: 107 additions & 20 deletions docs/workflow/blocks/map.rst
Original file line number Diff line number Diff line change
@@ -1,37 +1,124 @@
Map
===

The map block allows embedding a map for display of geocoded data.
Leaflet.JS is used to display the map, and some Leaflet.JS customisation
options are supported.
The Map block allows embedding a map for displaying geocoded data.
The Leaflet.js library is used to display the map, and some customisation options are supported.

The ``height`` in pixels of the block (default height 500px),
and the initial ``zoom`` level (default zoom = 8)
are provided as configuration options to the block. You can also optionally
provide a ``latlng`` config option for the default map center point.
The map defaults to centering at [51.505, -0.09].
provide a ``latlng`` config option for the default map centre point.

Examples
--------
The map defaults to centring at [51.505, -0.09].

The map block expects incoming data to be an array of objects that
have ``lat``, ``long``, and ``label`` properties. Here is an example
of a mapping that produces appropriate data:
Data input
----------

The Map block expects incoming data to be an array of objects.
Each object represents a marker, and/or a country area. Each object can include the following properties:

- **Location-based Pin:** Provide both ``lat`` and ``long`` properties to display a pin at the specified coordinates.
Copy link
Collaborator

Choose a reason for hiding this comment

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

Can you please write the properties exactly as they are in the object? We do like this in all the other blocks and common way of documenting.
So in this case it would be:
customPin: explanation
lat
long
label
and so on.

Copy link
Collaborator

Choose a reason for hiding this comment

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

- **Country-based Area:** Provide the ``country`` property with a 3-letter ISO country code (e.g., "GBR") to display the country area.

You can include both location and country properties in the same object to display both a pin and a country area.

Optionally, include a ``label`` property for the popup label and a ``customPin`` property for custom icons (see below).

**Example: Displaying location pins**
Copy link
Collaborator

Choose a reason for hiding this comment

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

Can we keep the format of "Examples" as chapter title and then the examples name as subtitle?
Like we do here: https://kendraio-app.readthedocs.io/en/latest/workflow/blocks/actions.html


This example shows a mapping that produces an array of objects, each representing a location with a pin:

.. code-block:: json

{
"type": "mapping",
"mapping": "[{ lat: `51.5074`, long: `-0.1278`, label: 'London' }, { lat: `48.8566`, long: `2.3522`, label: 'Paris' }]"
},
{
"type": "map",
"height": 500,
"zoom": 2
}

Displaying country areas
------------------------

To display a country area on the map, provide the ``country`` property in the data object
with the 3-letter ISO code for the country. For example, to display the United Kingdom, you would use ``"GBR"``.

You can configure the appearance of country areas using the ``countryStyle`` object in the block config.
The properties and their defaults are as follows:

.. code-block:: json

{
"type": "mapping",
"mapping": "data[?recovered > `20`].{ lat: lat, long: long, label: join(' ', [to_string(recovered), 'recovered in', combinedKey][? @ != null]) }"
}
{
Copy link
Collaborator

Choose a reason for hiding this comment

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

This is not clear to me. Where this countryStyle object should stay? Is another property of the object containing the country, the lat and long? Or where it stays?

"color": "gray",
"weight": 2,
"opacity": 0.3,
"fillColor": "gray",
"fillOpacity": 0.5
}

Example map block configuration:
**Example: Displaying country areas**
Copy link
Collaborator

Choose a reason for hiding this comment

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

I would suggest getting rid of this title and the countryStyle code and directly write the complete code example.

Copy link
Collaborator

Choose a reason for hiding this comment

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

I would say you can even write an example with the customPin. Is clear enough, I don't think it needs two separate example for it.


This example shows a mapping that produces an array of objects, each representing a country area. The map block configures the appearance of the country areas:

.. code-block:: json

{
"type": "mapping",
"mapping": "[{ country: 'GBR', label: 'United Kingdom' }, { country: 'FRA', label: 'France' }]"
},
{
"type": "map",
"height": 500,
"zoom": 2,
"countryStyle": {
"color": "red",
"fillColor": "pink"
}
}

**Example: Displaying both pins and country areas**

This example demonstrates displaying both a pin and a country area in the same object:

.. code-block:: json

{
"type": "map",
"height": 500,
"zoom": 2
}
{
"type": "mapping",
"mapping": "[{ country: 'GBR', lat: `51.5074`, long: `-0.1278`, label: 'London, United Kingdom', customPin: '🇬🇧' }]"
},
{
"type": "map",
"height": 500,
"zoom": 6,
"countryStyle": {
"color": "blue",
"fillColor": "lightblue"
}
}

Custom pin icons
----------------

In addition to the default pin icon, the block allows setting custom pin
icons using the ``customPin`` property in the data object. The ``customPin`` property
accepts a string value that can be plain text, HTML, or even emojis.
The input is sanitised with DOMPurify to prevent XSS attacks.

**Example: Custom pin icons with emojis**

This example uses emojis as custom pin icons for specific locations:
Copy link
Collaborator

Choose a reason for hiding this comment

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

I am confuse, I don't see any difference between this customPinvalue and previous example customPin value


.. code-block:: json

{
"type": "mapping",
"mapping": "[{ lat: `51.5074`, long: `-0.1278`, label: 'London', customPin: '🇬🇧' }, { lat: `48.8566`, long: `2.3522`, label: 'Paris', customPin: '🇫🇷' }]"
},
{
"type": "map",
"height": 500,
"zoom": 2
}
163 changes: 163 additions & 0 deletions src/app/blocks/map-block/map-block.component.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import { MapBlockComponent } from "./map-block.component";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { of } from "rxjs";
import { geoJSON } from "leaflet";

const mockGeoJsonData = {
type: "FeatureCollection",
features: [
{
type: "Feature",
geometry: {
type: "Polygon",
coordinates: [
[
[-5.0, 52.0],
[-4.0, 52.0],
[-4.0, 53.0],
[-5.0, 53.0],
[-5.0, 52.0],
],
],
},
properties: {
name: "Mock Country",
},
},
],
};

describe("MapBlockComponent", () => {
let component: MapBlockComponent;
let fixture: ComponentFixture<MapBlockComponent>;

beforeEach(() => {
TestBed.configureTestingModule({
declarations: [MapBlockComponent],
}).compileComponents();

fixture = TestBed.createComponent(MapBlockComponent);
component = fixture.componentInstance;
});

it("should create", () => {
Copy link
Collaborator

Choose a reason for hiding this comment

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

it should create... what? :)

expect(component).toBeTruthy();
});

it("should handle missing lat/long values if country is provided", (done) => {
const mockResponse = new Response(JSON.stringify(mockGeoJsonData), {
status: 200,
headers: { "Content-type": "application/json" },
});

spyOn(window, "fetch").and.returnValue(Promise.resolve(mockResponse));

const data = [{ country: "GBR", label: "United Kingdom" }];
component.onData(data, false);

setTimeout(() => {
try {
expect(component.layers.length).toBe(1); // Should create a single layer for the country
done();
} catch (error) {
done.fail(error);
}
}, 100); // Allow async operations to complete
});

it("should apply custom styles to country areas", () => {
component.onConfigUpdate({
countryStyle: { color: "red", fillColor: "pink" },
});
expect(component.countryStyle.color).toBe("red");
expect(component.countryStyle.fillColor).toBe("pink");
});

it("should create a custom marker icon with sanitised HTML", () => {
const data = [
{
lat: `51.5074`,
long: `-0.1278`,
label: "London",
customPin: '<span class="test">Test</span>',
},
];
component.onData(data, false);
expect(component.layers[0].options.icon.options.html).toContain(
'<span class="test">Test</span>'
);
});

it("should handle both lat/long and country in the same object", (done) => {
const mockResponse = new Response(JSON.stringify(mockGeoJsonData), {
status: 200,
headers: { "Content-type": "application/json" },
});

spyOn(window, "fetch").and.returnValue(Promise.resolve(mockResponse));

const data = [
{
country: "GBR",
lat: `51.5074`,
long: `-0.1278`,
label: "London, United Kingdom",
customPin: "🇬🇧",
},
];
component.onData(data, false);

setTimeout(() => {
try {
expect(component.layers.length).toBe(2); // Should create layers for both the pin and the country
done();
} catch (error) {
done.fail(error);
}
}, 100); // Allow async operations to complete
});

it("should update map height and zoom level based on configuration", () => {
component.onConfigUpdate({ height: 600, zoom: 10 });
expect(component.height).toBe(600);
expect(component.options.zoom).toBe(10);
});

it("should handle empty data input", () => {
component.onData([], false);
expect(component.layers.length).toBe(0); // No layers should be created
});

it("should create a custom marker icon with plain text", () => {
const data = [
{
lat: `51.5074`,
long: `-0.1278`,
label: "London",
customPin: "📍",
Copy link
Collaborator

Choose a reason for hiding this comment

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

shouldn't this be plain text?

},
];
component.onData(data, false);
expect(component.layers[0].options.icon.options.html).toContain("📍");
});

it("should use cached GeoJSON data if available", (done) => {
const countryCode = "GBR";
component.countryCache[countryCode] = mockGeoJsonData;

const spyFetch = spyOn(window, "fetch");

const data = [{ country: countryCode, label: "United Kingdom" }];
component.onData(data, false);

setTimeout(() => {
try {
expect(spyFetch).not.toHaveBeenCalled(); // Fetch should not be called if data is cached
expect(component.layers.length).toBe(1); // Should create a single layer for the country
done();
} catch (error) {
done.fail(error);
}
}, 100); // Allow async operations to complete
});
});
Loading
Loading