Community project
Interactive Global Flight Tracker
This project builds an interactive global flight tracker on an ESP32 microcontroller with a 3.5-inch touchscreen display. The tracker renders a zoomable world map with animated flight routes, allowing users to pan and zoom to explore different regions and watch aircraft traverse their paths in real time.
The guide provides a complete parts list, wiring diagram for connecting the ILI9488 display and XPT2046 touch controller to the ESP32, and fully commented firmware that handles map rendering, touch input, zoom controls, and flight animation. Assembly takes just minutes—connect power, wire the display data and control lines, attach the touch sensor pins, and load the sketch to start tracking flights.
Wiring diagram
Gather all the parts
Assemble it in 5 steps
1. Identify the screen header
Use the long header on the edge of your 3.5-inch screen. Read the tiny printed labels beside the pins. Do not use the separate SD_CS, SD_MOSI, SD_MISO, or SD_SCK pads for this project.
- The 3.5-inch and 4-inch V1.0 modules can have the same header labels; follow the printed label, not the screen size.
- Do not connect any screen pin to NodeMCU VIN or 5V — the NodeMCU signal pins use 3.3V.
2. Connect screen power
Connect screen VCC to NodeMCU 3V3 (power), screen GND to NodeMCU GND (ground), and screen LED to NodeMCU 3V3 (backlight power).
- With LED connected to 3V3, the backlight is always on.
- Make sure VCC and GND are not swapped — swapped power can damage the screen.
3. Connect the picture screen wires
Connect CS to NodeMCU D8 / GPIO15 (screen select), RESET to D0 / GPIO16 (screen reset), DC to D2 / GPIO4 (screen command-or-picture signal), MOSI to D7 / GPIO13 (picture data), and SCK to D5 / GPIO14 (picture timing signal). Leave the screen MISO pin unconnected.
- Use a separate wire colour for CS, RESET, and DC so they are easy to check.
- GPIO15/D8 must stay low while the NodeMCU starts, so do not add another pull-up resistor or device to the CS wire.
4. Connect the touch wires
Connect T_CS to NodeMCU D1 / GPIO5 (touch select). Join T_CLK to the same NodeMCU D5 / GPIO14 connection as SCK (touch timing), join T_DIN to the same NodeMCU D7 / GPIO13 connection as MOSI (touch data in), and connect T_DO to NodeMCU D6 / GPIO12 (touch data out). Leave T_IRQ unconnected.
- One NodeMCU pin can feed both labels: use a breadboard row or two jumper wires from D5 and D7.
- The touch labels begin with T_; the display labels do not.
- Do not accidentally connect T_DO to D7: T_DO must go to D6, or taps will not be detected.
5. Power and use the map
Plug the NodeMCU into your computer with USB. After deploying, the screen will show the world map and the yellow plane travelling across it. Tap the screen once to pause; tap it again to continue.
- If the backlight is on but no picture appears, unplug USB first and then recheck CS, RESET, DC, MOSI, and SCK.
- Always unplug USB before moving jumper wires so a loose wire cannot short 3V3 to GND.
Review all connections
1. Connections between "world_tft_1" and "ESP32"
Deploy the firmware
#include <SPI.h>
#include <Adafruit_GFX.h>
#include <ILI9488.h>
#include <XPT2046_Touchscreen.h>
#define TFT_CS_PIN 15
#define TFT_DC_PIN 4
#define TFT_RST_PIN 16
#define TOUCH_CS_PIN 5
ILI9488 tft(TFT_CS_PIN, TFT_DC_PIN, TFT_RST_PIN);
XPT2046_Touchscreen touch(TOUCH_CS_PIN);
const uint16_t SEA = 0x001B;
const uint16_t LAND = 0x24A3;
const uint16_t GRID = 0x18C3;
const uint16_t ROUTE = 0x07FF;
const uint16_t PLANE = 0xFFE0;
const uint16_t TITLE = 0xFFFF;
const uint16_t FLYING = 0x07E0;
const uint16_t PAUSED = 0xFD20;
const int SCREEN_W = 480;
const int SCREEN_H = 320;
const int STATUS_H = 28;
const int ROUTE_LEFT = 25;
const int ROUTE_RIGHT = 454;
const int ROUTE_MID_Y = 174;
const int ROUTE_AMPLITUDE = 70;
const uint16_t ROUTE_STEPS = 240;
bool flying = true;
uint8_t zoomLevel = 0; // 0 = whole world, 1 = 2x, 2 = 3x
int panX = 0;
int panY = 0;
uint16_t routeStep = 0;
unsigned long lastFrameMs = 0;
unsigned long lastTouchMs = 0;
const int MAP_CX = SCREEN_W / 2;
const int MAP_CY = 174;
int mapX(int x) { return MAP_CX + (x - MAP_CX) * (zoomLevel + 1) + panX; }
int mapY(int y) { return MAP_CY + (y - MAP_CY) * (zoomLevel + 1) + panY; }
void clampPan() {
int limitX = (SCREEN_W * zoomLevel) / 2;
int limitY = ((SCREEN_H - STATUS_H) * zoomLevel) / 2;
panX = constrain(panX, -limitX, limitX);
panY = constrain(panY, -limitY, limitY);
}
int routeY(int x) {
return ROUTE_MID_Y - (int)(ROUTE_AMPLITUDE * sin((x - ROUTE_LEFT) * 3.1415926f / (ROUTE_RIGHT - ROUTE_LEFT)));
}
void coast(const int *p, uint8_t n) {
for (uint8_t i = 0; i < n - 1; i++) tft.drawLine(mapX(p[i * 2]), mapY(p[i * 2 + 1]), mapX(p[i * 2 + 2]), mapY(p[i * 2 + 3]), 0x1362);
tft.drawLine(mapX(p[(n - 1) * 2]), mapY(p[(n - 1) * 2 + 1]), mapX(p[0]), mapY(p[1]), 0x1362);
}
void landPoly(const int *p, uint8_t n) {
// Triangle fan: enough for these compact coastline polygons and works without a framebuffer.
for (uint8_t i = 1; i < n - 1; i++) {
tft.fillTriangle(mapX(p[0]), mapY(p[1]), mapX(p[i * 2]), mapY(p[i * 2 + 1]), mapX(p[(i + 1) * 2]), mapY(p[(i + 1) * 2 + 1]), LAND);
}
}
void drawLand() {
// More detailed coastlines, plus a few faint country-style boundaries.
const int northAmerica[] = {25,102, 35,87, 43,76, 57,69, 75,61, 92,62, 109,65, 122,75, 129,83, 125,94, 120,105, 132,113, 141,122, 132,134, 122,142, 108,137, 95,133, 87,141, 79,151, 66,146, 54,140, 48,130, 45,120, 34,114};
const int southAmerica[] = {111,151, 124,154, 135,158, 143,170, 147,183, 143,198, 143,214, 136,230, 129,248, 113,260, 107,247, 104,229, 107,211, 107,198, 96,174};
const int greenland[] = {137,56, 148,47, 159,43, 171,46, 178,50, 181,62, 181,75, 171,84, 162,91, 151,85, 145,79};
const int eurasia[] = {190,91, 199,78, 210,68, 226,62, 245,58, 260,63, 274,68, 288,63, 302,57, 318,61, 341,65, 356,73, 384,84, 401,87, 421,91, 439,100, 452,111, 445,120, 433,126, 416,123, 400,121, 390,129, 381,136, 363,133, 346,129, 335,137, 326,145, 310,138, 294,131, 280,138, 269,143, 256,135, 242,130, 228,132, 214,128, 202,117};
const int africa[] = {243,143, 256,137, 271,134, 286,141, 299,149, 304,163, 307,180, 302,195, 295,218, 285,234, 274,249, 263,242, 254,236, 249,219, 241,202, 238,187, 230,174};
const int australia[] = {370,213, 384,205, 401,199, 419,204, 434,211, 446,222, 446,233, 438,242, 426,250, 408,249, 391,247, 378,240, 370,232};
const int madagascar[] = {318,224, 326,228, 325,238, 323,245, 316,242};
const int japan[] = {400,137, 406,143, 403,153, 396,148};
const int britain[] = {228,101, 235,96, 240,106, 235,114};
landPoly(northAmerica, 23); landPoly(southAmerica, 15); landPoly(greenland, 11);
landPoly(eurasia, 39); landPoly(africa, 18); landPoly(australia, 13);
landPoly(madagascar, 5); landPoly(japan, 4); landPoly(britain, 4);
coast(northAmerica, 23); coast(southAmerica, 15); coast(greenland, 11); coast(eurasia, 39); coast(africa, 18); coast(australia, 13); coast(madagascar, 5); coast(japan, 4); coast(britain, 4);
tft.fillCircle(79,164,3,LAND); tft.drawCircle(79,164,3,0x1362);
tft.fillCircle(459,176,3,LAND); tft.fillCircle(461,184,2,LAND); tft.fillCircle(457,252,2,LAND);
const uint16_t border = 0x2C45;
tft.drawLine(43,94,118,98,border); tft.drawLine(56,78,121,84,border);
tft.drawLine(106,179,138,182,border); tft.drawLine(108,205,141,200,border);
tft.drawLine(208,104,242,106,border); tft.drawLine(244,106,278,110,border); tft.drawLine(288,87,326,111,border);
tft.drawLine(252,164,299,176,border); tft.drawLine(247,191,294,185,border); tft.drawLine(258,216,284,225,border);
tft.drawLine(382,222,438,230,border);
// Extra coast detail: peninsulas, archipelagos, inland seas, and regional borders.
// These are drawn over the broad land shapes above so the map remains readable at 480 x 320.
const int alaska[] = {25,102, 20,96, 24,89, 31,85, 37,88, 40,94, 35,100};
const int centralAmerica[] = {108,137, 116,140, 123,146, 130,148, 133,153, 126,155, 119,150, 113,146};
const int florida[] = {119,105, 126,108, 130,115, 126,121, 120,117};
const int caribbean1[] = {128,151, 135,150, 141,153, 137,156, 130,155};
const int caribbean2[] = {145,158, 151,160, 155,163, 150,165};
const int scandinavia[] = {257,86, 260,77, 267,71, 272,75, 271,84, 276,91, 269,96};
const int iberia[] = {230,117, 239,119, 243,126, 238,133, 228,130};
const int italy[] = {267,123, 272,130, 278,136, 276,143, 271,138, 269,131};
const int balkans[] = {280,120, 289,124, 293,133, 288,140, 282,135};
const int arabia[] = {301,137, 314,143, 320,154, 313,163, 302,157};
const int india[] = {329,133, 341,139, 345,151, 340,161, 332,154};
const int seAsia[] = {351,145, 361,151, 365,163, 358,171, 351,164};
const int korea[] = {375,125, 381,129, 381,137, 377,141};
const int sumatra[] = {359,182, 366,189, 369,201, 365,207, 360,197};
const int borneo[] = {375,177, 385,179, 389,190, 382,197, 374,190};
const int java[] = {385,207, 406,208, 411,211, 390,213};
const int newGuinea[] = {425,188, 443,187, 452,194, 446,200, 430,199};
const int newZealand[] = {454,244, 461,250, 458,260, 453,266, 450,258};
const int japanChain[] = {399,137, 405,143, 403,153, 398,160, 395,166};
const int ukChain[] = {228,101, 235,96, 240,106, 235,114, 232,119};
const int iceland[] = {199,70, 207,66, 214,68, 216,72, 208,75};
coast(alaska, 7); coast(centralAmerica, 8); coast(florida, 5); coast(caribbean1, 5); coast(caribbean2, 4);
coast(scandinavia, 8); coast(iberia, 5); coast(italy, 6); coast(balkans, 5); coast(arabia, 5); coast(india, 5); coast(seAsia, 5); coast(korea, 4);
coast(sumatra, 5); coast(borneo, 5); coast(java, 4); coast(newGuinea, 5); coast(newZealand, 5); coast(japanChain, 5); coast(ukChain, 5); coast(iceland, 5);
// Ocean-coloured lakes and gulfs break up the solid continental fills.
tft.fillTriangle(58,89, 73,87, 76,97, SEA); tft.drawTriangle(58,89, 73,87, 76,97, 0x1362);
tft.fillTriangle(79,104, 95,101, 101,111, SEA); tft.drawTriangle(79,104, 95,101, 101,111, 0x1362);
tft.fillTriangle(259,112, 275,109, 280,120, SEA); tft.drawTriangle(259,112, 275,109, 280,120, 0x1362);
tft.fillTriangle(292,154, 303,157, 303,171, SEA); tft.drawTriangle(292,154, 303,157, 303,171, 0x1362);
tft.fillTriangle(275,151, 285,153, 288,165, SEA); tft.drawTriangle(275,151, 285,153, 288,165, 0x1362);
// Fine country-style boundaries: subtle enough that they do not hide the flight path.
const uint16_t fineBorder = 0x3466;
tft.drawLine(36,103,52,93,fineBorder); tft.drawLine(53,94,73,104,fineBorder); tft.drawLine(74,104,94,98,fineBorder);
tft.drawLine(92,75,93,132,fineBorder); tft.drawLine(111,151,120,179,fineBorder); tft.drawLine(120,180,139,191,fineBorder); tft.drawLine(106,211,139,211,fineBorder);
tft.drawLine(203,92,229,99,fineBorder); tft.drawLine(230,102,256,101,fineBorder); tft.drawLine(278,76,278,124,fineBorder); tft.drawLine(314,74,314,126,fineBorder);
tft.drawLine(335,76,336,126,fineBorder); tft.drawLine(359,83,359,128,fineBorder); tft.drawLine(386,90,386,122,fineBorder);
tft.drawLine(242,151,269,159,fineBorder); tft.drawLine(269,159,297,151,fineBorder); tft.drawLine(244,178,300,178,fineBorder); tft.drawLine(250,203,294,198,fineBorder);
tft.drawLine(371,225,393,219,fineBorder); tft.drawLine(394,219,420,229,fineBorder); tft.drawLine(404,207,405,245,fineBorder);
// Small islands that are visible at this scale.
const int islands[][2] = {{151,92},{158,99},{166,106},{183,84},{220,90},{222,97},{246,91},{323,128},{348,169},{369,174},{395,171},{416,177},{454,170},{459,177},{462,183},{452,203},{445,260}};
for (uint8_t i = 0; i < sizeof(islands) / sizeof(islands[0]); i++) {
tft.fillCircle(islands[i][0], islands[i][1], 1, LAND);
}
// Geographic relief: deserts, forests, mountain chains, and major lakes make
// the compact political-style basemap read more like a physical atlas.
const uint16_t desert = 0xDCE6;
const uint16_t forest = 0x13A5;
const uint16_t mountain = 0x7A85;
const uint16_t snow = 0xEF5D;
const uint16_t lake = 0x055D;
tft.fillTriangle(254,158,283,150,294,172,desert); // Sahara
tft.fillTriangle(281,145,306,145,310,158,desert); // Arabian Desert
tft.fillTriangle(329,106,357,99,366,119,desert); // Central Asia
tft.fillTriangle(385,224,423,217,433,238,desert); // Australian interior
tft.fillTriangle(55,108,88,103,96,126,forest); // North American forests
tft.fillTriangle(109,181,133,184,136,219,forest); // Amazon basin
tft.fillTriangle(247,182,274,181,283,211,forest); // Congo basin
tft.fillTriangle(347,103,385,104,397,124,forest); // East Asian forests
// Andes, Rockies, Alps/Himalaya and the Great Rift Valley.
for (int i = 0; i < 8; i++) {
int x = 113 + i * 3; int y = 169 + i * 10;
tft.fillTriangle(x, y + 5, x + 3, y - 4, x + 6, y + 5, mountain);
}
for (int i = 0; i < 9; i++) {
int x = 53 + i * 6; int y = 94 + i * 2;
tft.fillTriangle(x, y + 5, x + 3, y - 4, x + 6, y + 5, mountain);
}
for (int i = 0; i < 12; i++) {
int x = 276 + i * 5; int y = 123 + (i % 3) * 2;
tft.fillTriangle(x, y + 5, x + 3, y - 4, x + 6, y + 5, snow);
}
for (int i = 0; i < 5; i++) {
int x = 275 + i * 3; int y = 175 + i * 8;
tft.drawLine(x, y, x + 5, y + 7, mountain);
}
// Great Lakes, Caspian Sea, Aral region, and Lake Victoria.
tft.fillRoundRect(76,101,12,6,3,lake); tft.fillRoundRect(86,105,10,6,3,lake);
tft.fillRoundRect(94,102,8,4,2,lake); tft.fillRoundRect(278,122,14,8,4,lake);
tft.fillRoundRect(296,121,6,4,2,lake); tft.fillCircle(276,187,4,lake);
// Small white polar ice caps provide a familiar world-atlas silhouette.
tft.fillTriangle(0,30,120,30,77,49,snow);
tft.fillTriangle(360,30,480,30,421,49,snow);
tft.fillTriangle(0,304,120,319,0,319,snow);
tft.fillTriangle(360,319,480,304,480,319,snow);
// Region labels, drawn last so they remain legible over the relief colouring.
tft.setTextSize(1);
tft.setTextColor(0xBDF7);
tft.setCursor(53,126); tft.print(F("NORTH AMERICA"));
tft.setCursor(101,232); tft.print(F("SOUTH AMERICA"));
tft.setCursor(257,205); tft.print(F("AFRICA"));
tft.setCursor(297,116); tft.print(F("EUROPE / ASIA"));
tft.setCursor(391,236); tft.print(F("AUSTRALIA"));
tft.setTextColor(0x7DFF);
tft.setCursor(177,277); tft.print(F("PACIFIC OCEAN"));
tft.setCursor(160,57); tft.print(F("ARCTIC OCEAN"));
}
void drawGraticule() {
// Curved meridians and evenly spaced parallels make the flat map read like a globe.
for (int y = 68; y < SCREEN_H; y += 42) tft.drawFastHLine(0, y, SCREEN_W, GRID);
for (int baseX = 0; baseX <= SCREEN_W; baseX += 60) {
int lastX = baseX;
int lastY = STATUS_H;
for (int y = STATUS_H + 4; y < SCREEN_H; y += 4) {
int x = baseX + (int)(10.0f * sin((y - STATUS_H) * 3.1415926f / (SCREEN_H - STATUS_H)) * sin(baseX * 3.1415926f / SCREEN_W));
tft.drawLine(lastX, lastY, x, y, GRID);
lastX = x;
lastY = y;
}
}
}
void drawRoute() {
int lastX = ROUTE_LEFT;
int lastY = routeY(lastX);
for (int x = ROUTE_LEFT + 3; x <= ROUTE_RIGHT; x += 3) {
int y = routeY(x);
tft.drawLine(lastX, lastY, x, y, ROUTE);
lastX = x;
lastY = y;
}
}
void drawStatus() {
tft.fillRect(0, 0, SCREEN_W, STATUS_H, 0x0000);
tft.setTextSize(2);
tft.setTextColor(TITLE);
tft.setCursor(12, 7);
tft.print(F("GLOBAL FLIGHT PATH"));
tft.setTextColor(flying ? FLYING : PAUSED);
tft.setCursor(330, 7);
tft.print(flying ? F("FLYING ") : F("PAUSED "));
tft.print(zoomLevel + 1);
tft.print(F("X"));
}
void drawWorld() {
tft.fillScreen(SEA);
drawGraticule();
drawLand();
drawRoute();
drawStatus();
}
void drawPlane(int x, int y) {
tft.fillCircle(x, y, 3, PLANE);
tft.fillTriangle(x + 8, y, x - 5, y - 5, x - 5, y + 5, PLANE);
}
void resetFlight() {
routeStep = 0;
drawWorld();
}
void updateFlight() {
int x = ROUTE_LEFT + (routeStep * (ROUTE_RIGHT - ROUTE_LEFT)) / (ROUTE_STEPS - 1);
drawPlane(x, routeY(x));
routeStep++;
if (routeStep >= ROUTE_STEPS) resetFlight();
}
void checkTouch() {
if (!touch.touched() || millis() - lastTouchMs <= 350) return;
TS_Point first = touch.getPoint();
int previousX = first.x;
int previousY = first.y;
int startX = first.x;
int startY = first.y;
bool dragged = false;
unsigned long started = millis();
unsigned long lastRedraw = 0;
while (touch.touched() && millis() - started < 1900) {
TS_Point point = touch.getPoint();
int movedX = point.x - startX;
int movedY = point.y - startY;
if (abs(movedX) > 70 || abs(movedY) > 70) dragged = true;
if (dragged) {
// XPT2046 reports raw touch units. Moving about 700 raw units is roughly one screen width.
panX += (point.x - previousX) * SCREEN_W / 700;
panY += (point.y - previousY) * (SCREEN_H - STATUS_H) / 700;
if (zoomLevel == 0) zoomLevel = 1; // A slide opens a useful, movable 2x map view.
clampPan();
if (millis() - lastRedraw >= 45) {
drawWorld();
lastRedraw = millis();
}
}
previousX = point.x;
previousY = point.y;
delay(10);
}
unsigned long heldMs = millis() - started;
lastTouchMs = millis();
if (dragged) {
drawWorld();
} else if (heldMs >= 1300) {
// Long hold: zoom out one level and centre the map if it reaches whole-world view.
if (zoomLevel > 0) zoomLevel--;
if (zoomLevel == 0) { panX = 0; panY = 0; }
clampPan();
resetFlight();
} else if (heldMs >= 550) {
// Medium hold: zoom in one level, keeping the current map position.
if (zoomLevel < 2) zoomLevel++;
clampPan();
resetFlight();
} else {
// Short tap pauses or resumes the plane.
flying = !flying;
drawStatus();
}
}
void setup() {
SPI.begin();
tft.begin();
tft.setRotation(1);
touch.begin();
touch.setRotation(1);
resetFlight();
}
void loop() {
checkTouch();
unsigned long now = millis();
if (flying && now - lastFrameMs >= 50) {
lastFrameMs = now;
updateFlight();
}
delay(5);
}Remix this project
Make it yours in one click
Open a full copy of this project in your own Schematik workspace — diagram, code, parts, and assembly steps included. Swap the sensor, add features, or redesign the whole thing with AI. The author's original stays untouched.




