7.0.1850-testing

Writing a Web App from scratch

This page serves as a end-to-end example project for the JavaScript bindings of GreyCat with an emphasis on Web development.

For the complete documentation of the library @greycat/web, read the dedicated documentation.

Requirements

This tutorial requires:

  • Node.js (>= 18)
  • npm (or pnpm, yarn or any JavaScript package manager)
  • Any recent Chromium-based browser, Firefox or Safari.
  • GreyCat 7 (see Setup)

Setup

To get started, lets ensure that GreyCat is installed globally:

curl -fsSL https://get.greycat.io/install.sh | bash -s testing

This tutorial assumes the host machine’s OS is Linux or MacOS. For the Windows installation, follow the instructions at https://get.greycat.io and adapt the different commands to a Windows environment.

Using a template

We provide templates to get you started easily with Web projects. Lets scaffold our project by cloning template/web:

git clone git@github.com:datathings/greycat-template-web.git sensors-project

This will clone the template into a newly created directory: sensors-project

Once the project is cloned, move into it and install the required dependencies:

cd sensors-project
pnpm install

This template comes with everything setup for a good Web development experience.

.
├── src
│   └── ...   # GreyCat Server files
├── frontend
│   └── ...   # TypeScript files
├── ...       # Project-level configuration files

Backend development

In this section we are going to write the server part of the project composed of:

  • modeling the domain
  • writing CSV importers

Defining the project’s domain

The goal of this project is to visualize sensors data coming from a GreyCat server into a Web application.
Lets pretend that the sensors data is coming from an external 3rd-party server under the form of CSV files, but for the sake of this tutorial those files are going to be already available as static files.

Although periodically fetching external files and importing them into GreyCat is interesting and totally doable, it is out of scope for this tutorial.
The focus being on displaying data with @greycat/web rather than importing data.

To envision more what the data looks like, let’s show an excerpt:

SensorID,Latitude,Longitude,Type,Value,Timestamp
1,48.8566,2.3522,Temperature,23.5,2023-11-14T12:00:00
2,51.5074,-0.1278,Humidity,65.2,2023-11-14T12:05:00
3,49.6116,6.1319,Pressure,1012.3,2023-11-14T12:10:00
...
2,51.5074,-0.1278,CO2 Level,435,2023-11-14T20:05:00
3,49.6116,6.1319,Temperature,23.3,2023-11-14T20:10:00
4,59.9139,10.7522,Humidity,61.0,2023-11-14T20:15:00

The complete file is available to download here: sensors.csv
Download the file and add it at the root of the project in: data/sensors.csv.
Here’s a one-liner to do it:

mkdir -p data && wget https://doc.greycat.io/tutorials/advanced_web_app/assets/sensors.csv -O data/sensors.csv

Model the data

Looking back at the data/sensors.csv file, we see that we have:

Column Name Description
SensorID int
Latitude float
Longitude float
Type a kind of sensor from the following list: Temperature, Humidity, Light Intensity
Value the actual value provided by the sensor as a float
Timestamp a normalized UTC datetime that we are going to map as a time

This translates almost 1:1 to GreyCat’s type system, so lets create a file in order to write our types:

# create a dedicated directory
mkdir -p src/model

# create a file for the types
touch src/model/sensors.gcl

Then in src/model/sensors.gcl paste the following:

type Sensor {
    /// Maps to `SensorID`
    id: int;
    /// Maps to `Latitude` and `Longitude`
    location: geo;
    /// Maps `Type=Temperature`, `Value`, `Timestamp`
    temperature: nodeTime<float>;
    /// Maps `Type=Humidity`, `Value`, `Timestamp`
    humidity: nodeTime<float>;
    /// Maps `Type=Light Intensity`, `Value`, `Timestamp`
    light: nodeTime<float>;
}

We create a Sensor type that contains timeseries for every possible types: temperature, humidity and light.
Now that we have our model setup, we can write an importer.

Import the data

In order to import the data, we need to import it somewhere, that somewhere will be a nodeIndex named sensors that will keep track of all the project sensors.
Lets open the project.gcl file at the root of the sensors-demo directory and write the following:

Defining a DTO to speed up and simplify data import from a csv


@volatile
enum SensorDataType{
  Temperature;
  Humidity;
  Pressure;
}
@volatile
type SensorDTO{
  id: int;
  coords: geo; // will automatically consume two columns
  data_type: SensorDataType?; // will automatically infer the correct enum based on matching string if  none matches null will be set since we specified ?
  value: float;
  date: time; // automatically transforms the iso date into our native time type
}
@include("backend/src");

var sensors_by_id: nodeIndex<int, Sensor>;
var prevPos: node<int?>; // Store the position so we can only import new data incase it gets incremntally updated

fn main() {
  var reader = CsvReader<SensorDTO>{path:"data/sensors.csv", pos: *prevPos, format: CsvFormat {header_lines: 1}};
  while (reader.can_read()) {
    var line = reader.read();

    var sensor = sensors_by_id.get(line.id) ?? sensors_by_id.set(line.id, Sensor {
      id: line.id,
      location: line.coords,
      temperature: nodeTime<float>{},
      humidity: nodeTime<float>{},
      light: nodeTime<float>{},
    });
    // fill appropriate time series based on data type column
    if (line.data_type == SensorDataType::Temperature) {
      sensor.temperature.setAt(line.date, line.value);
    } else if (line.data_type ==SensorDataType::Humidity) {
      sensor.humidity.setAt(line.date, line.value);
    } else {
      sensor.light.setAt(line.date, line.value);
    }
  }
  prevPos.set(reader.pos);
}

In order to ensure a certain level of type safety, we defined a SensorDTO that complies with our sensors.csv file.
Then comes the actual iteration of the lines in the CSV file by leveraging CsvReader.

You can refer to Features > CSV to get more insight into how CSV importers work in GreyCat.

Finally, we retrieve a Sensor instance from our sensors_by_id index, or we create a new one if none. And based on the ‘Type’ column, we fill the proper timeserie with the value and timestamp.

There we have it, our sensors.csv is imported in our GreyCat graph, and we can now query it.

Create an API

In order to create an API in GreyCat, we only need to create functions that we @expose. Lets do this.
The only API we need for our project is a way to get the list of available sensors.

This is done quite easily by returning an Array<Sensor>:

// in project.gcl

@expose
fn sensors(): Array<Sensor> {
  var result = Array<Sensor> {};
  for (_, sensor in sensors_by_id) {
    result.add(sensor);
  }
  return result;
}

Moving on to the frontend part.

Frontend development

From GCL to Javascript, the power of type Reflexivity

GreyCat comes with its own TypeScript declaration file (d.ts) code generator to allow type-safe access of types, enums and @exposed functions from TypeScript And/Or JavaScript.

For us to be able to use the Sensor type in the front and the @exposed function sensors(), we will need to actually generate the TypeScript code associated to it. For this, the template we cloned at the beginning comes with a script already setup:

pnpm gen

This will auto-generate a project.d.ts file based on your project modules.

Start GreyCat in serve mode

Let’s start our GreyCat server in serve mode, so that it can answer queries:

greycat serve --user=1

--user=1 means that every requests are impersonated to be the root user, effectively bypassing authentication as this is out of the scope of this tutorial.

In another terminal now we will start our Web development server. This server will do 2 things:

  • hot-reload the Web page when a changes happen in a TypeScript file
  • proxy the API requests to our actual GreyCat server
pnpm dev

We can now open our favorite browser, and go to: http://localhost:5173

You should be greeted with an “The index page” message:

Display the sensors as-is

When quick prototyping Web applications, you often want to see what’s inside the response you get from the server. For this we’ve created two different Web component:

  • gui-value which is going to print any given value as a string
  • gui-object which is a more involved printer, that actually knows how to display complex data structures

To get started with our sensors list, we are going to display them using <gui-object />. In frontend/index/index.tsx, replace with the flowing

import '~/common/components/app-layout';

await gc.sdk.init();

const sensors = await gc.sensors();

document.body.appendChild(
  <app-layout>
    <div slot="main">
      <gui-object value={sensors}> </gui-object>
    </div>
  </app-layout>,
);

Once the change is made, save the file, and the browser should reload with the content of the sensors: Sensors list

This is handy, but not ideal in our case. We can see our 4 sensors, but we actually would like to see the different captors data in, say, a table.

Display the timeseries in a table

As said before, <gui-object /> is good for prototyping, or displaying scalar values or POJOs, but when it comes to timeseries, often times, you want them to be displayed in a table containing 2 columns, one for the time and one for the value.

This is why every node type in GreyCat (nodeTime, nodeList, nodeIndex and nodeGeo) can be sampled to return a Table.
And because the Table type is in the std library, we also made a Web Component that knows how to display it.

Therefore, we are going to remove <gui-object /> , and make a dedicated Web Component for displaying sensors.

Instead of displaying the sensors variable using a <gui-object /> we are going to iterate over the sensors, and for each entry, create a Web Component called <app-sensor /> to which we give a reference to the sensor.

This means that we need to add another file frontend/common/components/app-sensor.tsx with the following content:

import { GuiElement, registerCustomElement } from '@greycat/web';

export class AppSensor extends GuiElement {
  private _sensor!: gc.Sensor;
  private _header: HTMLElement = document.createElement('span');
  private _content: HTMLElement = document.createElement('div');

  constructor() {
    super();

    this.shadowRoot.replaceChildren(
      <sl-card style={{ width: '100%' }}>
        <div slot="header">{this._header}</div>
        {this._content}
      </sl-card>,
    );
  }

  set value(sensor: gc.Sensor) {
    this._sensor = sensor;
    this.update();
  }

  async update(): Promise<void> {
    this._header.textContent = `Sensor #${this._sensor.id}`;

    try {
      const data = await gc.nodeTime.sample(
        [this._sensor.temperature],
        null,
        null,
        100,
        gc.SamplingMode.dense,
        null,
        null,
      );
      data.headers = ['Timestamp', 'Temperature'];
      this._content.replaceChildren(<gui-table value={data} />);
    } catch (err) {
      this._content.innerHTML = gc.sdk.prettyError(err, 'something went wrong');
    }
  }
}

declare global {
  interface HTMLElementTagNameMap {
    'app-sensor': AppSensor;
  }

  namespace JSX {
    interface IntrinsicElements {
      'app-sensor': GreyCat.Element<AppSensor>;
    }
  }
}

registerCustomElement('app-sensor', AppSensor);

With our custom web component we just need to map it our desired type and automatically passing an object of that type will return our custom web component

import { GuiFactory } from '@greycat/web';
import '~/common/components/app-layout'; // import and register ou web comp
import '~/common/components/app-sensor';

await gc.sdk.init();

GuiFactory.global.set(gc.Sensor._type, 'app-sensor'); // Add to our internal global component factory

const sensors = await gc.sensors();

document.body.appendChild(
  <app-layout>
    <div slot="main">
      {sensors.map((v) => {
        return <gui-object value={v}> </gui-object>;
      })}
    </div>
  </app-layout>,
);

Example

Create a sensor

We now want to be able to create and register our own sensors manually from the Web application.
We would first need a new @exposed function in our server for this. Lets modify project.gcl and add the following:

// in project.gcl
// ...

@expose
fn createSensor(id: int, lat: float, lng: float) {
  var sensor = Sensor {
    id: id,
    location: geo{lat, lng},
    temperature: nodeTime<float>{},
    humidity: nodeTime<float>{},
    light: nodeTime<float>{},
  };
  sensors_by_id.set(id, sensor);
}

Save the file, restart GreyCat server by hitting Ctrl+c and then run greycat serve --user=1 again.
Also, because we’ve modified our public-facing API, we need to regenerate our TypeScript bindings by running pnpm gen.

Then, we go back to our frontend, and modify frontend/index/index.tsx to add a form for sensor creation. By leveraging @greycat/web’s dynamic input, we can do this with little to no-code:

import '~/common/components/app-layout';

await gc.sdk.init();

const object = document.createElement('gui-object');
async function fetchSensors() {
  const sensors = await gc.sensors();
  object.value = sensors;
}
const form = document.createElement('gui-input-fn');
form.value = new gc.project.createSensor$args(5, 6.5, 5.5);
const button = document.createElement('sl-button');
button.textContent = 'Add';
button.addEventListener('click', async () => {
  await gc.project.createSensor.apply(null, form.args);
  fetchSensors();
});
document.body.appendChild(
  <app-layout>
    <div slot="main">
      {form}
      {button}
      {object}
    </div>
  </app-layout>,
);

// Fetch data on first render;
fetchSensors();