Giter Site home page Giter Site logo

kristoisberg / samp-gps-plugin Goto Github PK

View Code? Open in Web Editor NEW
41.0 4.0 9.0 106 KB

Modern and feature-rich pathfinding plugin for open.mp and SA-MP.

Pawn 13.44% C++ 81.85% CMake 4.70%
pawn-package pawn sa-mp sa-mp-plugin gps pathfinding pathfinder

samp-gps-plugin's Introduction

SA-MP GPS Plugin

sampctl

Notice: This repository is not being actively maintained anymore. If anyone wishes to continue the development of the project, please create a fork of the repository and release future versions there.

This plugin offers a way of accessing and manipulating the data of San Andreas map nodes and finding paths between them. It is intended to be a modern and straightforward replacement for RouteConnector. The plugin uses a simple implementation of the A* algorithm for pathfinding. Finding a path from the top-leftmost node on the map to the bottom-rightmost node that consists of 684 nodes takes just a few milliseconds.

Advantages over RouteConnector

  • Safer API - Unlike RouteConnector, this plugins does not give you an array of nodes as the result of pathfinding. Instead of that, it gives you the ID of the found path that can be used later on. Each function (except IsValidMapNode, IsValidPath and GetHighestMapNodeID) returns an error code and the real result of them is passed by reference.
  • Compatibility - RouteConnector has a compatibility issue with some part of YSI that makes it call a wrong public function instead of the actual GPS_WhenRouteIsCalculated callback. This plugin lets you call a custom callback and pass arguments to it. In addition to that, RouteConnector uses Intel Threading Building Blocks for threading that has caused numerous compatibility (and PEBCAK) issues on Linux servers. This plugin uses std::thread for threading and does not have any dependencies. This plugin is also compatible with PawnPlus and supports asynchronous pathfinding out of box.
  • Performance - I have not done any benchmarks, but even with older versions users claimed that this plugin is multiple times faster than RouteConnector. A fix in the algorithm in version 1.2.0 made it around 30 times faster than it previously was.

Installation

Simply install to your project:

sampctl package install kristoisberg/samp-gps-plugin

Include in your code and begin using the library:

#include <GPS>

API

Functions

CreateMapNode(Float:x, Float:y, Float:z, &MapNode:nodeid)

  • Adds a node to the map and passes the ID of it to nodeid.

DestroyMapNode(MapNode:nodeid)

  • If the specified map node is valid, returns GPS_ERROR_NONE and tries to destroy it, otherwise returns GPS_ERROR_INVALID_NODE. If the node is not a part of any path, it gets destroyed immediately, otherwise it will be destroyed once all paths containing it are destroyed, until that it will be excluded from pathfinding and several other features.

bool:IsValidMapNode(MapNode:nodeid)

  • Returns if the map node with the specified ID is valid.

GetMapNodePos(MapNode:nodeid, &Float:x, &Float:y, &Float:z)

  • If the specified map node is valid, returns GPS_ERROR_NONE and passes the position of it to x, y and z, otherwise returns GPS_ERROR_INVALID_NODE.

CreateConnection(MapNode:source, MapNode:target, &Connection:connectionid)

  • If both specified map nodes are valid, returns GPS_ERROR_NONE, creates a connection from source to target and passes the ID of it to connectionid, otherwise returns GPS_ERROR_INVALID_NODE. Note: Connections are not double-sided may need to be added in both directions separately.

DestroyConnection(Connection:connectionid)

  • If the specified connection is valid, returns GPS_ERROR_NONE and destroys it, otherwise returns GPS_ERROR_INVALID_CONNECTION.

GetConnectionSource(Connection:connectionid, &MapNode:nodeid)

  • If the specified connection is valid, returns GPS_ERROR_NONE and passes the ID of the source node of it to nodeid, otherwise returns GPS_ERROR_INVALID_CONNECTION.

GetConnectionTarget(Connection:connectionid, &MapNode:nodeid)

  • If the specified connection is valid, returns GPS_ERROR_NONE and passes the ID of the target node of it to nodeid, otherwise returns GPS_ERROR_INVALID_CONNECTION.

GetMapNodeConnectionCount(MapNode:nodeid, &count)

  • If the specified map node is valid, returns GPS_ERROR_NONE and passes the amount of its connections to count, otherwise returns GPS_ERROR_INVALID_NODE. If count is larger than 2, the node is an intersection.

GetMapNodeConnection(MapNode:nodeid, index, &Connection:connectionid)

  • If the specified map node is valid and it has a connection with the specified index, returns GPS_ERROR_NONE and passes the ID of the connection to connectionid, otherwise returns GPS_ERROR_INVALID_NODE or GPS_ERROR_INVALID_CONNECTION depending on the error.

GetConnectionBetweenMapNodes(MapNode:source, MapNode:target, &Connection:connectionid)

  • If both specified map nodes are valid, tries to find a connection from source to target. If a connection is found, returns GPS_ERROR_NONE and passes the ID of the connection to connectionid, otherwise returns GPS_ERROR_INVALID_CONNECTION. If either of the specified map nodes is invalid, returns GPS_ERROR_INVALID_NODE.

GetDistanceBetweenMapNodes(MapNode:first, MapNode:second, &Float:distance)

  • If both of the specified map nodes are valid, returns GPS_ERROR_NONE and passes the distance between them to distance, otherwise returns GPS_ERROR_INVALID_NODE.

GetAngleBetweenMapNodes(MapNode:first, MapNode:second, &Float:angle)

  • If both of the specified map nodes are valid, returns GPS_ERROR_NONE and passes the angle between them to angle, otherwise returns GPS_ERROR_INVALID_NODE.

GetMapNodeDistanceFromPoint(MapNode:nodeid, Float:x, Float:y, Float:z, &Float:distance)

  • If the specified map node is valid, returns GPS_ERROR_NONE and passes the distance of the map node from the specified position to distance, otherwise returns GPS_ERROR_INVALID_NODE.

GetMapNodeAngleFromPoint(MapNode:nodeid, Float:x, Float:y, &Float:angle)

  • If the specified map node is valid, returns GPS_ERROR_NONE and passes the angle of the map node from the specified position to angle, otherwise returns GPS_ERROR_INVALID_NODE.

GetClosestMapNodeToPoint(Float:x, Float:y, Float:z, &MapNode:nodeid, MapNode:ignorednode = INVALID_MAP_NODE_ID)

  • Passes the ID of the closest map node to the specified position to nodeid. If ignorednode is specified and it is the closest node to the position, it is ignored and the ID of the next closest node is passed to nodeid instead. Returns GPS_ERROR_INVALID_NODE if no nodes exist, otherwise returns GPS_ERROR_NONE.

GetHighestMapNodeID()

  • Returns the ID of the map node with the highest ID. Could be used for iteration purposes.

GetRandomMapNode(&MapNode:nodeid)

  • Passes the ID of a random map node, found using Mersenne Twister, to nodeid. Returns GPS_ERROR_INVALID_NODE if no map nodes exist, otherwise returns GPS_ERROR_NONE.

SaveMapNodesToFile(const filename[])

  • Saves all existing nodes and their connections to a file with the specified name.

FindPath(MapNode:source, MapNode:target, &Path:pathid)

  • If both of the specified map nodes are valid, returns GPS_ERROR_NONE and tries to find a path from source to target and pass its ID to pathid, otherwise returns GPS_ERROR_INVALID_NODE. If pathfinding fails, returns GPS_ERROR_INVALID_PATH.

FindPathThreaded(MapNode:source, MapNode:target, const callback[], const format[] = "", {Float, _}:...)

  • If both of the specified map nodes are valid, returns GPS_ERROR_NONE and tries to find a path from source to target. After pathfinding is finished, calls the specified callback and passes the path ID (could be INVALID_PATH_ID if pathfinding fails) and the specified arguments to it.

Task:FindPathAsync(MapNode:source, MapNode:target)

  • Pauses the current function and continues it after it is finished. Throws an AMX error if pathfinding fails for any reason. Only available if PawnPlus is included before GPS. Usage explained below.

bool:IsValidPath(Path:pathid)

  • Returns if the path with the specified ID is valid.

GetPathSize(Path:pathid, &size)

  • If the specified path is valid, returns GPS_ERROR_NONE and passes the amount of nodes in it to size, otherwise returns GPS_ERROR_INVALID_PATH.

GetPathNode(Path:pathid, index, &MapNode:nodeid)

  • If the specified path is valid and the index contains a node, returns GPS_ERROR_NONE and passes the ID of the node at that index to nodeid, otherwise returns GPS_ERROR_INVALID_PATH or GPS_ERROR_INVALID_NODE depending on the error.

GetPathNodeIndex(Path:pathid, MapNode:nodeid, &index)

  • If the specified path is valid and the specified map node is a part of the path, returns GPS_ERROR_NONE and passes the index of the map node to index, otherwise returns GPS_ERROR_INVALID_PATH or GPS_ERROR_INVALID_NODE depending on the error.

GetPathLength(Path:pathid, &Float:length)

  • If the specified path is valid, returns GPS_ERROR_NONE and passes the length of the path in metres to length, otherwise returns GPS_ERROR_INVALID_PATH.

DestroyPath(Path:pathid)

  • If the specified path is valid, returns GPS_ERROR_NONE and destroys the path, otherwise returns GPS_ERROR_INVALID_PATH.

Error codes

  • GPS_ERROR_NONE - The function was executed successfully.
  • GPS_ERROR_INVALID_PARAMS - An invalid amount of arguments was passed to the function. Should never happen without the PAWN compiler noticing it unless the versions of the plugin and include are different.
  • GPS_ERROR_INVALID_PATH - An invalid path ID as passed to the function or threaded pathfinding was not successful.
  • GPS_ERROR_INVALID_NODE - An invalid map node ID/index was passed to the function or GetClosestMapNodeToPoint or GetRandomMapNode failed because no map nodes exist.
  • GPS_ERROR_INVALID_CONNECTION - An invalid connection ID/index was passed to the function.
  • GPS_ERROR_INTERNAL - An internal error happened - threaded pathfinding failed because dispatching a thread failed.

Examples

Threaded pathfinding

Finding a path from the position of the player to the LSPD building.

CMD:pathtols(playerid) {
    new Float:x, Float:y, Float:z, MapNode:start;
    GetPlayerPos(playerid, x, y, z);

    if (GetClosestMapNodeToPoint(x, y, z, start) != GPS_ERROR_NONE) {
        return SendClientMessage(playerid, COLOR_RED, "Finding a node near you failed, GPS.dat was not loaded.");
    }

    new MapNode:target;

    if (GetClosestMapNodeToPoint(1258.7352, -2036.7100, 59.4561, target)) { // this is also valid since the value of GPS_ERROR_NONE is 0.
        return SendClientMessage(playerid, COLOR_RED, "Finding a node near LSPD failed, GPS.dat was not loaded.");
    }

    if (FindPathThreaded(start, target, "OnPathToLSFound", "ii", playerid, GetTickCount())) {
        return SendClientMessage(playerid, COLOR_RED, "Pathfinding failed for some reason, you should store this error code and print it out since there are multiple ways it could fail.");
    }

    SendClientMessage(playerid, COLOR_WHITE, "Finding the path...");
    return 1;
}


forward public OnPathToLSFound(Path:pathid, playerid, start_time);
public OnPathToLSFound(Path:pathid, playerid, start_time) {
    if (!IsValidPath(pathid)) {
        return SendClientMessage(playerid, COLOR_RED, "Pathfinding failed!");
    }

    new string[128], size, length;
    GetPathSize(size);
    GetPathLength(length);

    format(string, sizeof(string), "Found a path in %ims. Amount of nodes: %i, length: %fm.", GetTickCount() - start_time, size, length);

    new MapNode:nodeid, Float:x, Float:y, Float:z;

    for (new index; index < size; index++) {
        GetPathNode(pathid, index, nodeid);
        GetMapNodePos(nodeid, x, y, z);
        CreateDynamicPickup(1318, 1, x, y, z);
    }

    DestroyPath(pathid);
    return 1;
}

Asynchronous pathfinding

What if you could continue the process within the command while still taking advantage of the benefits of threaded pathfinding? You can, using the magic of PawnPlus tasks.

CMD:pathtols(playerid) {
    new Float:x, Float:y, Float:z, MapNode:start;
    GetPlayerPos(playerid, x, y, z);

    if (GetClosestMapNodeToPoint(x, y, z, start)) {
        return SendClientMessage(playerid, COLOR_RED, "Finding a node near you failed, GPS.dat was not loaded.");
    }

    new MapNode:target;

    if (GetClosestMapNodeToPoint(1258.7352, -2036.7100, 59.4561, target)) { 
        return SendClientMessage(playerid, COLOR_RED, "Finding a node near LSPD failed, GPS.dat was not loaded.");
    }

    SendClientMessage(playerid, COLOR_WHITE, "Finding the path...");

    new Path:pathid = task_await(FindPathAsync(start, target)); // no error handling here, an AMX error will be thrown instead if the pathfinding fails

    new string[128], size, length;
    GetPathSize(size);
    GetPathLength(length);

    format(string, sizeof(string), "Found a path in %ims. Amount of nodes: %i, length: %fm.", GetTickCount() - start_time, size, length);

    new MapNode:nodeid, Float:x, Float:y, Float:z, index;

    while (!GetPathNode(pathid, index, nodeid)) // also note the alternative method of iterating through path nodes here
        GetMapNodePos(nodeid, x, y, z);
        CreateDynamicPickup(1318, 1, x, y, z);

        index++;
    }

    DestroyPath(pathid);
    return 1;
}

Testing

To test, simply run the package:

sampctl package run

Credits

  • kristo - Creator of the plugin.
  • Gamer_Z - Creator of the original RouteConnector plugin which helped me understand the structure of GPS.dat and influenced this plugin a lot, the author of the original GPS.dat.
  • NaS - Author of the fixed GPS.dat distributed with the plugin.
  • Southclaws, IllidanS4, Hual - Helped me with the plugin in major ways (there were other helpful people as well, I appreciate it all).

samp-gps-plugin's People

Contributors

amyrahmady avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar

samp-gps-plugin's Issues

Run time error

Server cfg:

plugins crashdetect streamer sscanf Whirlpool pawncmd mapandreas mysql GPS nativechecker

Errors:

[01:33:19] [debug] Run time error 4: "Array index out of bounds"
[01:33:19] [debug] AMX backtrace:
[01:33:19] [debug] #0 0001ba58 in CodeScanAddJumpTarget (cip=-2392276, stk=0, hea=0, jumpTargets[CodeScanner:164]=@01ab64b0, next=32, sip=-2455984) at C:\Users\pc\Desktop\Gamemode\pawno\include\YSI_Data\y_foreach....\YSI_Coding\y_hooks..\y_cgen....\YSI_Core\y_core....\amx\codescan.inc:262
[01:33:19] [debug] #1 0001c0bc in CodeScanAddSwitchTarget (dctx[DisasmContext:22]=@01ab5c44, stk=0, hea=0, jumpTargets[CodeScanner:164]=@01ab64b0, next=-2455984) at C:\Users\pc\Desktop\Gamemode\pawno\include\YSI_Data\y_foreach....\YSI_Coding\y_hooks..\y_cgen....\YSI_Core\y_core....\amx\codescan.inc:294
[01:33:19] [debug] #2 0001e53c in bool:CodeScanStepInternal (dctx[DisasmContext:22]=@01ab5c44, csState[CodeScanner:164]=@01ab64b0, &parseState=@01ab5c38 0, &parseParam=@01ab5c34 0) at C:\Users\pc\Desktop\Gamemode\pawno\include\YSI_Data\y_foreach....\YSI_Coding\y_hooks..\y_cgen....\YSI_Core\y_core....\amx\codescan.inc:747
[01:33:19] [debug] #3 0001ea88 in bool:CodeScanRun (csState[CodeScanner:164]=@01ab64b0) at C:\Users\pc\Desktop\Gamemode\pawno\include\YSI_Data\y_foreach....\YSI_Coding\y_hooks..\y_cgen....\YSI_Core\y_core....\amx\codescan.inc:817
[01:33:19] [debug] #4 0001ffc0 in AddressofResolve () at C:\Users\pc\Desktop\Gamemode\pawno\include\YSI_Data\y_foreach....\YSI_Coding\y_hooks..\y_cgen....\YSI_Core\y_core....\amx\addressof_jit.inc:130
[01:33:19] [debug] #5 0002058c in public AMX_OnCodeInit () at C:\Users\pc\Desktop\Gamemode\pawno\include\YSI_Data\y_foreach....\YSI_Coding\y_hooks..\y_cgen....\YSI_Core\y_core\y_thirdpartyinclude.inc:379
[01:33:19] [debug] #6 00010ecc in public Debug_OnCodeInit () at C:\Users\pc\Desktop\Gamemode\pawno\include\YSI_Data\y_foreach....\YSI_Core\y_core\y_amx_impl.inc:206
[01:33:19] [debug] #7 00010820 in public ScriptInit_OnCodeInit () at C:\Users\pc\Desktop\Gamemode\pawno\include\YSI_Data\y_foreach....\YSI_Core\y_core\y_debug_impl.inc:659
[01:33:19] [debug] #8 0000fc38 in public FIXES_OnGameModeInit () at C:\Users\pc\Desktop\Gamemode\pawno\include\YSI_Data\y_foreach....\YSI_Core\y_core\y_scriptinit_impl.inc:319
[01:33:19] [debug] #9 00004344 in public OnGameModeInit () at C:\Users\pc\Desktop\Gamemode\pawno\include\fixes.inc:6015
[01:33:19] [debug] Run time error 4: "Array index out of bounds"
[01:33:19] [debug] AMX backtrace:
[01:33:19] [debug] #0 0004a12c in Malloc_TrySetup () at C:\Users\pc\Desktop\Gamemode\pawno\include\YSI_Coding\y_timers..\y_malloc\y_malloc_heapalloc.inc:226
[01:33:19] [debug] #1 00049b24 in main () at C:\Users\pc\Desktop\Gamemode\pawno\include\YSI_Coding\y_timers..\y_malloc\y_malloc_heapalloc.inc:78
[01:33:19] Script[gamemodes/mode.amx]: Run time error 4: "Array index out of bounds"
[01:33:19] Number of vehicle models: 0

All everything up to date.

Run time error 4 , run time error 29

04:33:48] [debug] Run time error 4: "Array index out of bounds"
[04:33:48] [debug] Attempted to read/write array element at index 29 in array of size 29
[04:33:48] [debug] AMX backtrace:
[04:33:48] [debug] #0 00467a0c in public AC_OnPlayerSpawn (3) in nexalrp.amx
[04:33:48] [debug] #1 00089908 in public WC_OnPlayerSpawn (3) in nexalrp.amx
[04:33:48] [debug] #2 00071740 in public ac_OnPlayerSpawn (3) in nexalrp.amx
[04:33:48] [debug] #3 00038f0c in public OnPlayerSpawn (3) in nexalrp.amx

[14:41:36] [debug] Run time error 4: "Array index out of bounds"
[14:41:36] [debug] Attempted to read/write array element at index 29 in array of size 29
[14:41:36] [debug] AMX backtrace:
[14:41:36] [debug] #0 004646c0 in public AC_OnPlayerSpawn (0) in nexalrp.amx
[14:41:36] [debug] #1 00089908 in public WC_OnPlayerSpawn (0) in nexalrp.amx
[14:41:36] [debug] #2 00071740 in public ac_OnPlayerSpawn (0) in nexalrp.amx
[14:41:36] [debug] #3 00038f0c in public OnPlayerSpawn (0) in nexalrp.amx

How to fix this errors

Error plugin load CXXABI_1.3.9

Hi, please make static version for

Loading plugin: GPS.so
Failed (/usr/lib/i386-linux-gnu/libstdc++.so.6: version `CXXABI_1.3.9' not found (required by plugins/GPS.so))

I can't run plugin on host

Failed to load plugin

[20/04/2019 01:58:24] Loading plugin: GPS.so
[20/04/2019 01:58:24] Failed (/usr/lib/libstdc++.so.6: version `GLIBCXX_3.4.20' not found (required by plugins/GPS.so))

Plugin not loading

I've added gps to the plugin line in the server cfg, and I'm getting this error in the console:

Loading plugin: gps
Plugin does not conform to architecture.

AddNodeToPath & RemoveNodeToPath

lets say im on a gps route and I go off path, or take a shortcut, must I recalculate the route instantly? No. That's a CPU shink. Let us add nodes to the path and its not until you go wayyy off path then we should consider recaulcating.

Error trying to compile in Centos6. Problem with 32bit library compiled with 64bit system

Hello, I'm tryng to compile the plugin because when I run the release version on my samp server I have this error: Failed (/usr/lib/libstdc++.so.6: version `GLIBCXX_3.4.20' not found (required by plugins/GPS.so))
//////////////////////////////////////////////////// make ///////////////
root: make
gcc -std=c++17 -c -m32 -fPIC -O3 -w -DLINUX -I./lib/sdk/amx/ ./lib/sdk/amxplugin.cpp
gcc -std=c++17 -c -m32 -fPIC -O3 -w -DLINUX -I./lib/sdk/amx/ ./lib/sdk/amxplugin2.cpp
gcc -std=c++17 -c -m32 -fPIC -O3 -w -DLINUX -I./lib/sdk/amx/ ./src/*.cpp
In file included from /usr/local/include/c++/9.2.0/random:49,
from ./src/natives.cpp:4:
/usr/local/include/c++/9.2.0/bits/random.h:103:26: error: expected unqualified-id before โ€˜__int128โ€™
103 | { typedef unsigned __int128 type; };
| ^~~~~~~~
make: *** [all] Error 1

//////////////////////////////////////////////////////////////////
samp-gps-plugin-master]# gcc --version: gcc (GCC) 9.2.0
//////////////////////////////////////////////////////////////////

What can be the problem? thanks.

Streamer plugin error

Hello, i've just added this GPS plugin and script compiles with no errors, but when I start server this is what I get:

[19:22:55] *** Streamer Plugin: The plugin version (0x295) is older than the include file version (0x296) for this script. The plugin might need to be updated to the latest version. [19:22:55] [debug] Run time error 19: "File or function is not found" [19:22:55] [debug] GetClosestMapNodeToPoint [19:22:55] [debug] FindPath [19:22:55] [debug] GetPathNode [19:22:55] [debug] GetMapNodePos [19:22:55] [debug] GetMapNodeAngleFromPoint [19:22:55] [debug] Run time error 19: "File or function is not found" [19:22:55] [debug] GetClosestMapNodeToPoint [19:22:55] [debug] FindPath [19:22:55] [debug] GetPathNode [19:22:55] [debug] GetMapNodePos [19:22:55] [debug] GetMapNodeAngleFromPoint

I don't understand why. Streamer plugin and include version are both 2.96. I've also tried to download plugin/include version of 2.95 but same problem.

error occurs when i use gps plugin for openmp

use plugins folder:
[2023-11-05T13:40:25+0700] [Info] Loading plugin: GPS.dll
[2023-11-05T13:40:25+0700] [Info] This file is not a SA-MP plugin.

use components folder:
Loading component GPS.dll
Failed to load component: it is neither an open.mp component nor a SA-MP plugin.

please help me.
I used google translate to compose this text.

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    ๐Ÿ–– Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. ๐Ÿ“Š๐Ÿ“ˆ๐ŸŽ‰

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google โค๏ธ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.