summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorAlexander NeonXP Kiryukhin <a.kiryukhin@mail.ru>2019-05-14 01:05:07 +0300
committerAlexander NeonXP Kiryukhin <a.kiryukhin@mail.ru>2019-05-14 01:05:07 +0300
commit0c9db775302d15483385f0621611583e3a2407cd (patch)
treef1268582b38f66b91b84225c23f6a3b1d7ff0239 /src
Initial commit
Diffstat (limited to 'src')
-rw-r--r--src/Actions/actions.ts9
-rw-r--r--src/Actions/auth.ts44
-rw-r--r--src/Actions/entity.ts105
-rw-r--r--src/Api/api.ts251
-rw-r--r--src/Api/entityDecoder.ts55
-rw-r--r--src/Api/interfaces.ts70
-rw-r--r--src/Api/types.ts30
-rw-r--r--src/Components/Login.tsx71
-rw-r--r--src/Components/Map.tsx180
-rw-r--r--src/Components/MapObjects.tsx90
-rw-r--r--src/Components/MapOverlay.tsx91
-rw-r--r--src/Components/PortalPanel.tsx70
-rw-r--r--src/Main.js35
-rw-r--r--src/Store/store.ts63
-rw-r--r--src/colors.ts3
-rw-r--r--src/helper.js46
16 files changed, 1213 insertions, 0 deletions
diff --git a/src/Actions/actions.ts b/src/Actions/actions.ts
new file mode 100644
index 0000000..988ba65
--- /dev/null
+++ b/src/Actions/actions.ts
@@ -0,0 +1,9 @@
+import auth from './auth'
+import entity from './entity'
+
+export const actions = {
+ ...auth,
+ ...entity,
+}
+
+export default actions \ No newline at end of file
diff --git a/src/Actions/auth.ts b/src/Actions/auth.ts
new file mode 100644
index 0000000..fb26c98
--- /dev/null
+++ b/src/Actions/auth.ts
@@ -0,0 +1,44 @@
+import { Dispatch } from 'redux';
+
+const auth = {
+ 'login': () => (dispatch: Dispatch) => {
+ dispatch({
+ type: 'authLoad',
+ loading: true,
+ })
+ fetch("https://www.ingress.com/intel", { "credentials": "include", "referrer": "https://intel.ingress.com/" })
+ .then(r => r.text())
+ .then(t => {
+ const s1 = t.match(/gen_dashboard_(.+?)\.js/)
+ if (s1 == null) {
+ throw new Error("V not found")
+ }
+ const v = s1[1]
+ const s2 = t.match(/var PLAYER = (.+?)\;\n/)
+ if (s2 == null) {
+ throw new Error("user not found")
+ }
+ const user = JSON.parse(s2[1])
+ const s3 = t.match(/\<input type='hidden' name='csrfmiddlewaretoken' value='(.+?)' \/\>/)
+ if (s3 == null) {
+ throw new Error("csrf not found")
+ }
+ const csrf = s3[1]
+ return { v, user, csrf }
+ // })
+ // .then(({ v, user }) => {
+ // return CookieManager.get("https://intel.ingress.com")
+ // .then(cookies => {
+ // const csrf = cookies['csrftoken']
+ // return { v, csrf, user }
+ // })
+ }).then(({ v, csrf, user }) =>
+ dispatch({
+ type: 'authSet',
+ v, csrf, user
+ })
+ )
+ }
+}
+
+export default auth \ No newline at end of file
diff --git a/src/Actions/entity.ts b/src/Actions/entity.ts
new file mode 100644
index 0000000..632b505
--- /dev/null
+++ b/src/Actions/entity.ts
@@ -0,0 +1,105 @@
+
+import { Dispatch, Store } from 'redux';
+import { Region } from 'react-native-maps';
+import { getTilesToLoad, loadTiles, getPortalDetails } from '../Api/api';
+import { Portal, Link, Field } from '../Api/types';
+import { LoadedResult } from '../Api/interfaces';
+import { decodePortal } from '../Api/entityDecoder';
+
+const entity = {
+ 'update': (region: Region, width: number, refresh: boolean) => async (dispatch, getStore) => {
+ const store = getStore()
+ let tiles = getTilesToLoad(region, width)
+ if (!refresh) {
+ const loadedAlready = Object.keys(store.entities.portals)
+ tiles = tiles.filter(t => loadedAlready.indexOf(t) == -1)
+ }
+ const display = store.entities.display
+ dispatch(entity.loadTiles(tiles, display))
+ },
+ 'loadTiles': (tiles: string[], display: any) => async (dispatch, getStore) => {
+ const store = getStore()
+ const { v, csrf } = store.auth
+ const params = { v, csrf }
+ dispatch(entity.entitiesLoad(tiles.length))
+ setImmediate(() => loadTiles(tiles, params)
+ .then((loadedData: LoadedResult) => dispatch(entity.receiveTiles(loadedData, display))))
+ },
+ 'receiveTiles': (loadedData: LoadedResult, display: any) => async (dispatch) => {
+ dispatch(entity.portalsSet(loadedData.portals))
+ dispatch(entity.linksSet(loadedData.links))
+ dispatch(entity.fieldsSet(loadedData.fields))
+ Object.keys(loadedData.portalsByTile).forEach(tile => {
+ loadedData.portalsByTile[tile].forEach(p => {
+ if (display.portals.indexOf(p) == -1) {
+ display.portals.push(p)
+ }
+ })
+ })
+ Object.keys(loadedData.linksByTile).forEach(tile => {
+ loadedData.linksByTile[tile].forEach(p => {
+ if (display.links.indexOf(p) == -1) {
+ display.links.push(p)
+ }
+ })
+ })
+ Object.keys(loadedData.fieldsByTile).forEach(tile => {
+ loadedData.fieldsByTile[tile].forEach(p => {
+ if (display.fields.indexOf(p) == -1) {
+ display.fields.push(p)
+ }
+ })
+ })
+ dispatch(entity.entitiesDisplay(display))
+ if (loadedData.failedTiles.length > 0) {
+ setImmediate(() => {
+ dispatch(entity.loadTiles(loadedData.failedTiles, display))
+ })
+ } else {
+ dispatch(entity.entitiesLoad(0))
+ }
+ },
+ 'getPortalDetails': (guid: string) => async (dispatch, getStore) => {
+ const store = getStore()
+ const { v, csrf } = store.auth
+ const params = { v, csrf }
+ getPortalDetails(guid, params).then(j => {
+ const portal = decodePortal(j.result)
+ portal.fullLoad = true
+ dispatch(entity.portalSet(guid, portal))
+ })
+ },
+ 'entitiesDisplay': (display: any) => ({
+ type: 'entitiesDisplay',
+ display
+ }),
+ 'entitiesLoad': (loading: number) => ({
+ type: 'entitiesLoad',
+ loading
+ }),
+ 'portalsSet': (portals: { [guid: string]: Portal }) => ({
+ type: 'portalsSet',
+ portals,
+ }),
+ 'linksSet': (links: { [guid: string]: Link }) => ({
+ type: 'linksSet',
+ links,
+ }),
+ 'fieldsSet': (fields: { [guid: string]: Field }) => ({
+ type: 'fieldsSet',
+ fields,
+ }),
+ 'portalSet': (guid: string, portal: Portal) => ({
+ type: 'portalSet',
+ guid, portal,
+ }),
+ 'linkSet': (guid: string, link: Link) => ({
+ type: 'linksSet',
+ guid, link,
+ }),
+ 'fieldSet': (guid: string, field: Field) => ({
+ type: 'fieldsSet',
+ guid, field,
+ })
+}
+export default entity \ No newline at end of file
diff --git a/src/Api/api.ts b/src/Api/api.ts
new file mode 100644
index 0000000..2da4594
--- /dev/null
+++ b/src/Api/api.ts
@@ -0,0 +1,251 @@
+import { Region, LoadedResult, BoundingBox, TileParameters, GetEntitiesResponse, GetPortalResponse } from "./interfaces";
+import { Portal, Field, Link } from "./types";
+import { decodeEntity } from "./entityDecoder";
+
+const baseHeaders = {
+ 'Content-Type': 'application/json; charset=UTF-8',
+ 'Pragma': 'no-cache',
+ 'Accept': '*/*',
+ 'Host': 'intel.ingress.com',
+ 'Accept-Language': 'ru',
+ 'Cache-Control': 'no-cache',
+ 'Accept-Encoding': 'br, gzip, deflate',
+ 'Origin': 'https://intel.ingress.com',
+ 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_4) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/12.1 Safari/605.1.15',
+ 'Referer': 'https://intel.ingress.com/',
+}
+
+
+
+//getPlexts : {"minLatE6":55740916,"minLngE6":49151362,"maxLatE6":55752993,"maxLngE6":49221056,"minTimestampMs":-1,"maxTimestampMs":-1,"tab":"all","v":"93c872fd0763076709bed329e421c37f5fa45fa4"}
+//getPortalDetails : {"guid":"e9ac08ad60db42abb948970defb28548.16","v":"93c872fd0763076709bed329e421c37f5fa45fa4"}
+
+type Cache = {
+ portals: {
+ [tile: string]: string[]
+ },
+ links: {
+ [tile: string]: string[]
+ },
+ fields: {
+ [tile: string]: string[]
+ }
+}
+const cache: Cache = {
+ fields: {},
+ links: {},
+ portals: {},
+}
+const ZOOM_TO_LEVEL = [8, 8, 8, 8, 7, 7, 7, 6, 6, 5, 4, 4, 3, 2, 2, 1, 1]
+const TILES_PER_EDGE = [1, 1, 1, 40, 40, 80, 80, 320, 1000, 2000, 2000, 4000, 8000, 16000, 16000, 32000]
+const ZOOM_TO_LINK_LENGTH = [200000, 200000, 200000, 200000, 200000, 60000, 60000, 10000, 5000, 2500, 2500, 800, 300, 0, 0]
+
+export const getTilesToLoad = (region: Region, width: number): string[] => {
+ const zoom = getZoomByRegion(width, region)
+ const dataZoom = getDataZoomForMapZoom(zoom);
+ const tileParams = getMapZoomTileParameters(dataZoom)
+ const bbox = getBoundingBox(region)
+ const x1 = lngToTile(bbox.west, tileParams);
+ const x2 = lngToTile(bbox.east, tileParams);
+ const y1 = latToTile(bbox.north, tileParams);
+ const y2 = latToTile(bbox.south, tileParams);
+ let tilesToLoad: string[] = []
+ for (var y = y1; y <= y2; y++) {
+ for (var x = x1; x <= x2; x++) {
+ const tile_id = pointToTileId(x, y, tileParams);
+ tilesToLoad.push(tile_id)
+ }
+ }
+ return tilesToLoad
+}
+
+export const loadTiles = async (tilesToLoad: string[], params: RequestParams): Promise<LoadedResult> => {
+ const chunk = tilesToLoad.splice(0, 25)
+ const loadedData: LoadedResult = {
+ fields: {},
+ links: {},
+ portals: {},
+ portalsByTile: {},
+ fieldsByTile: {},
+ linksByTile: {},
+ failedTiles: tilesToLoad,
+ loaded: false,
+ }
+ await getEntities(chunk, params)
+ .then(loaded => {
+ const result = loaded.result
+ if (!result || !result['map']) {
+ return false
+ }
+ return Object.keys(result['map']).map(tile => {
+ if (result['map'][tile]['error'] || !result['map'][tile]['gameEntities']) {
+ loadedData.failedTiles.push(tile)
+ return true
+ }
+ if (!loadedData.portalsByTile[tile]) {
+ loadedData.portalsByTile[tile] = []
+ }
+ if (!loadedData.fieldsByTile[tile]) {
+ loadedData.fieldsByTile[tile] = []
+ }
+ if (!loadedData.linksByTile[tile]) {
+ loadedData.linksByTile[tile] = []
+ }
+ return result['map'][tile]['gameEntities'].map((e: any[]) => {
+ const guid = e[0]
+ const de = decodeEntity(e)
+ if (de instanceof Portal) {
+ if (loadedData.portalsByTile[tile].indexOf(guid) == -1) {
+ loadedData.portalsByTile[tile].push(guid)
+ }
+ loadedData.portals[guid] = de
+ } else if (de instanceof Field) {
+ if (loadedData.fieldsByTile[tile].indexOf(guid) == -1) {
+ loadedData.fieldsByTile[tile].push(guid)
+ }
+ loadedData.fields[guid] = de
+ } else if (de instanceof Link) {
+ if (loadedData.linksByTile[tile].indexOf(guid) == -1) {
+ loadedData.linksByTile[tile].push(guid)
+ }
+ loadedData.links[guid] = de
+ }
+ return true
+ })
+ })
+ }).catch(e => {
+ loadedData.failedTiles = [...loadedData.failedTiles, ...chunk]
+ })
+ return loadedData
+}
+
+const getBoundingBox = (region: Region): BoundingBox => {
+ return {
+ east: region.longitude + (region.longitudeDelta / 2),
+ north: region.latitude + (region.latitudeDelta / 2),
+ south: region.latitude - (region.latitudeDelta / 2),
+ west: region.longitude - (region.longitudeDelta / 2),
+ }
+}
+
+const lngToTile = (lng: any, tileParams: TileParameters) => {
+ return Math.floor((lng + 180) / 360 * tileParams.tilesPerEdge);
+}
+
+const latToTile = (lat: any, tileParams: TileParameters) => {
+ return Math.floor((1 - Math.log(Math.tan(lat * Math.PI / 180) +
+ 1 / Math.cos(lat * Math.PI / 180)) / Math.PI) / 2 * tileParams.tilesPerEdge);
+}
+
+const tileToLng = (x: number, params: TileParameters): number => {
+ return x / params.tilesPerEdge * 360 - 180;
+}
+
+const tileToLat = (y: number, params: TileParameters): number => {
+ var n = Math.PI - 2 * Math.PI * y / params.tilesPerEdge;
+ return 180 / Math.PI * Math.atan(0.5 * (Math.exp(n) - Math.exp(-n)));
+}
+
+const pointToTileId = (x: number, y: number, params: TileParameters): string => {
+ //change to quadkey construction
+ //as of 2014-05-06: zoom_x_y_minlvl_maxlvl_maxhealth
+
+ return params.zoom + "_" + x + "_" + y + "_" + params.level + "_8_100";
+}
+
+const getZoomByRegion = (width: number, region: Region): number => {
+ return Math.ceil(Math.log2(360 * ((width / 256) / region.longitudeDelta))) + 1
+}
+
+const getDataZoomForMapZoom = (zoom: number): number => {
+ if (zoom > 21) {
+ zoom = 21;
+ }
+ var origTileParams = getMapZoomTileParameters(zoom);
+ while (zoom > 3) {
+ var newTileParams = getMapZoomTileParameters(zoom - 1);
+
+ if (newTileParams.tilesPerEdge != origTileParams.tilesPerEdge
+ || newTileParams.hasPortals != origTileParams.hasPortals
+ || newTileParams.level * (newTileParams.hasPortals ? 1 : 0) != origTileParams.level * (origTileParams.hasPortals ? 1 : 0) // multiply by 'hasPortals' bool - so comparison does not matter when no portals available
+ ) {
+ // switching to zoom-1 would result in a different detail level - so we abort changing things
+ break;
+ } else {
+ // changing to zoom = zoom-1 results in identical tile parameters - so we can safely step back
+ // with no increase in either server load or number of requests
+ zoom = zoom - 1;
+ }
+ }
+ return zoom;
+}
+
+const getMapZoomTileParameters = (zoom: number): TileParameters => {
+ var level = ZOOM_TO_LEVEL.slice(0, 15)[zoom] || 0;
+
+ if (level <= 7 && level >= 4) {
+ level = level + 1;
+ }
+
+ var maxTilesPerEdge = TILES_PER_EDGE[TILES_PER_EDGE.length - 1];
+
+ return {
+ level: level,
+ maxLevel: ZOOM_TO_LEVEL.slice(0, 15)[zoom] || 0, // for reference, for log purposes, etc
+ tilesPerEdge: TILES_PER_EDGE[zoom] || maxTilesPerEdge,
+ minLinkLength: ZOOM_TO_LINK_LENGTH[zoom] || 0,
+ hasPortals: zoom >= ZOOM_TO_LINK_LENGTH.length, // no portals returned at all when link length limits things
+ zoom: zoom // include the zoom level, for reference
+ };
+}
+
+type RequestParams = {
+ csrf: string, v: string
+}
+
+function timeout(ms, promise) {
+ return new Promise(function (resolve, reject) {
+ setTimeout(function () {
+ reject(new Error("timeout"))
+ }, ms)
+ return promise.then(resolve, reject)
+ })
+}
+
+const getEntities = (tileKeys: string[], params: RequestParams): Promise<GetEntitiesResponse> => {
+ const promise = fetch("https://intel.ingress.com/r/getEntities", {
+ "credentials": 'include',
+ "headers": {
+ ...baseHeaders,
+ 'X-CSRFToken': params.csrf,
+ },
+ "referrer": "https://intel.ingress.com/",
+ "body": JSON.stringify({ tileKeys, v: params.v }),
+ "method": "POST",
+ })
+ return timeout(10000, promise.then(r => {
+ if (r.status != 200) {
+ return { result: { map: {} } }
+ }
+ return r.json()
+ }))
+}
+
+export const getPortalDetails = (guid: string, params: RequestParams): Promise<GetPortalResponse> => {
+ const promise = fetch("https://intel.ingress.com/r/getPortalDetails", {
+ "credentials": 'include',
+ "headers": {
+ ...baseHeaders,
+ 'X-CSRFToken': params.csrf,
+ },
+ "referrer": "https://intel.ingress.com/",
+ "body": JSON.stringify({ guid, v: params.v }),
+ "method": "POST",
+ })
+ return timeout(10000, promise.then(r => {
+ if (r.status != 200) {
+ return { result: [] }
+ }
+ return r.json()
+ }))
+}
diff --git a/src/Api/entityDecoder.ts b/src/Api/entityDecoder.ts
new file mode 100644
index 0000000..96d2247
--- /dev/null
+++ b/src/Api/entityDecoder.ts
@@ -0,0 +1,55 @@
+import { Portal, Link, Field } from "./types";
+
+export const decodeEntity = (entity: any[]): Portal | Link | Field | null => {
+ switch (entity[2][0]) {
+ case 'r':
+ // Field
+ return decodeField(entity[2])
+ case 'p':
+ //Portal
+ return decodePortal(entity[2])
+ case 'e':
+ // Link
+ return decodeLink(entity[2])
+ default:
+ return null
+ }
+}
+
+export const decodePortal = (e: any[]): Portal => {
+ return new Portal(
+ e[8],
+ e[7],
+ e[1],
+ { latitude: e[2] / 1E6, longitude: e[3] / 1E6, guid: e[8] },
+ e[4],
+ e[6],
+ e[10],
+ e[5],
+ e[13],
+ e[15],
+ e[14],
+ e[16],
+ )
+}
+
+export const decodeField = (e: any[]): Field => {
+ return new Field(
+ e[1],
+ [
+ { latitude: e[2][0][1] / 1E6, longitude: e[2][0][2] / 1E6, guid: e[2][0][0] },
+ { latitude: e[2][1][1] / 1E6, longitude: e[2][1][2] / 1E6, guid: e[2][1][0] },
+ { latitude: e[2][2][1] / 1E6, longitude: e[2][2][2] / 1E6, guid: e[2][2][0] }
+ ]
+ )
+}
+
+export const decodeLink = (e: any[]): Link => {
+ return new Link(
+ e[1],
+ [
+ { latitude: e[3] / 1E6, longitude: e[4] / 1E6, guid: e[2] },
+ { latitude: e[6] / 1E6, longitude: e[7] / 1E6, guid: e[5] }
+ ]
+ )
+} \ No newline at end of file
diff --git a/src/Api/interfaces.ts b/src/Api/interfaces.ts
new file mode 100644
index 0000000..779fcf1
--- /dev/null
+++ b/src/Api/interfaces.ts
@@ -0,0 +1,70 @@
+import { Portal, Link, Field } from "./types";
+
+export interface TileParameters {
+ level: number,
+ maxLevel: number,
+ tilesPerEdge: number,
+ minLinkLength: number,
+ hasPortals: boolean
+ zoom: number,
+}
+
+export interface BoundingBox {
+ west: number
+ east: number
+ north: number
+ south: number
+}
+
+export interface GetEntitiesResponse {
+ result: {
+ map: {
+ [tile: string]: {
+ gameEntities: any[]
+ error: string
+ }
+ }
+ }
+}
+
+export interface GetPortalResponse {
+ result: any[]
+}
+
+export interface LoadedResult {
+ loaded: boolean,
+ portalsByTile: {
+ [tile: string]: string[]
+ },
+ linksByTile: {
+ [tile: string]: string[]
+ },
+ fieldsByTile: {
+ [tile: string]: string[]
+ },
+ portals: {
+ [guid: string]: Portal
+ },
+ links: {
+ [guid: string]: Link
+ },
+ fields: {
+ [guid: string]: Field
+ },
+ failedTiles: string[]
+}
+
+
+export interface LatLng {
+ guid: string;
+ latitude: number;
+ longitude: number;
+
+}
+
+export interface Region {
+ readonly latitude: number;
+ readonly longitude: number;
+ readonly latitudeDelta: number;
+ readonly longitudeDelta: number;
+} \ No newline at end of file
diff --git a/src/Api/types.ts b/src/Api/types.ts
new file mode 100644
index 0000000..7a26bc8
--- /dev/null
+++ b/src/Api/types.ts
@@ -0,0 +1,30 @@
+import { LatLng } from "./interfaces";
+
+export class Portal {
+ fullLoad: boolean
+ constructor(
+ public name: string,
+ public photo: string,
+ public fraction: string,
+ public coords: LatLng,
+ public level: number,
+ public resonatorsCount: number,
+ public mission: boolean,
+ public power: number,
+ public timestamp: number,
+ public resonators: any[],
+ public mods: any[],
+ public owner: string
+
+ ) {
+ this.fullLoad = false
+ }
+}
+
+export class Link {
+ constructor(public fraction: string, public coords: LatLng[]) { }
+}
+
+export class Field {
+ constructor(public fraction: string, public coords: LatLng[]) { }
+} \ No newline at end of file
diff --git a/src/Components/Login.tsx b/src/Components/Login.tsx
new file mode 100644
index 0000000..6fe7957
--- /dev/null
+++ b/src/Components/Login.tsx
@@ -0,0 +1,71 @@
+import React, { Component } from 'react';
+import { StyleSheet, Text, View, WebView } from 'react-native';
+
+import { connect } from 'redux-su';
+import { getBottomSpace, getStatusBarHeight } from '../helper';
+import actions from '../Actions/actions';
+
+const LOGIN_URL = "https://accounts.google.com/ServiceLogin?service=ah&passive=true&continue=https://appengine.google.com/_ah/conflogin%3Fcontinue%3Dhttps://intel.ingress.com/";
+const HOME_URL = "https://intel.ingress.com/";
+
+var styles = StyleSheet.create({
+ container: {
+ flexGrow: 1,
+ }
+});
+
+type Props = {
+ login(csrf: string, v: string): void
+ actions: any
+ auth: any
+}
+
+type State = {
+ v: string
+ onIngress: boolean
+}
+
+class Login extends Component<Props, State> {
+ webview!: WebView;
+ constructor(props: Props) {
+ super(props);
+ this.state = { v: "", onIngress: false }
+ }
+
+ onNavigationStateChange(navState: WebViewNavigation) {
+ if (navState.url == HOME_URL) {
+ this.setState({ onIngress: true })
+ this.props.actions.login()
+ }
+ }
+
+ renderLogin() {
+ if (this.state.onIngress) {
+ return (<Text>Пожалуйста, подождите...</Text>);
+ }
+ return (
+ <>
+ <WebView
+ ref={r => r && (this.webview = r)}
+ automaticallyAdjustContentInsets={false}
+ thirdPartyCookiesEnabled
+ useWebKit
+ style={styles.container}
+ source={{ uri: LOGIN_URL }}
+ javaScriptEnabled={true}
+ onNavigationStateChange={this.onNavigationStateChange.bind(this)}
+ startInLoadingState={true}
+ />
+ </>
+ );
+ }
+
+ render() {
+ console.log(this.props)
+ return (<View style={{ flexGrow: 1, paddingTop: getStatusBarHeight(true), paddingBottom: getBottomSpace() }}>
+ {this.renderLogin()}
+ </View>);
+ }
+}
+
+export default connect({ 'auth': 'auth' }, actions)(Login) \ No newline at end of file
diff --git a/src/Components/Map.tsx b/src/Components/Map.tsx
new file mode 100644
index 0000000..3e878f8
--- /dev/null
+++ b/src/Components/Map.tsx
@@ -0,0 +1,180 @@
+import React, { Component } from 'react';
+import { StyleSheet, View, Dimensions, ActivityIndicator } from 'react-native';
+import MapView, { Marker, Region, UrlTile } from 'react-native-maps';
+import { connect } from 'redux-su';
+import { NavigationActions } from 'react-navigation';
+
+import MapOverlay from './MapOverlay';
+import MapObjects from './MapObjects';
+import PortalPanel from './PortalPanel';
+import { getBottomSpace } from '../helper';
+import actions from '../Actions/actions';
+import { LatLng } from '../Api/interfaces';
+
+const { width, height } = Dimensions.get("screen")
+const draggableRange = {
+ top: height / 1.75,
+ bottom: 120 + getBottomSpace()
+}
+
+type Props = any
+type State = any
+class Map extends Component<Props, State> {
+ refresh: number | undefined
+ map!: MapView;
+ constructor(props: Props) {
+ super(props)
+ this.state = {
+ user: undefined,
+ followUser: true,
+ zoom: 15,
+ region: null,
+ loading: false,
+ objects: { links: {}, fields: {}, portals: {}, loaded: false },
+ }
+ }
+ componentWillMount() {
+ navigator.geolocation.getCurrentPosition(
+ position => {
+ this.setState({
+ followUser: true,
+ });
+ },
+ error => alert(error.message),
+ { enableHighAccuracy: true, timeout: 20000, maximumAge: 1000 }
+ );
+ navigator.geolocation.watchPosition(
+ position => {
+ this.setState({
+ user: {
+ latitude: position.coords.latitude,
+ longitude: position.coords.longitude
+ },
+ });
+ },
+ error => alert(error.message),
+ { enableHighAccuracy: true, timeout: 20000, maximumAge: 1000 }
+ )
+ }
+ onRegionChange = (region: Region) => {
+ const zoom = this.getZoomByRegion(width, region)
+ this.setState({ region, zoom })
+ setImmediate(() => this.load(false))
+ }
+ getZoomByRegion(width: number, region: Region): number {
+ return Math.ceil(Math.log2(360 * ((width / 256) / region.longitudeDelta))) + 1
+ }
+ load = async (refresh: boolean) => {
+ if (this.state.region != null && this.props.entities.loading == 0) {
+ this.props.actions.update(this.state.region, width, refresh)
+ }
+ return null
+ }
+
+ refreshByTime = () => {
+ setTimeout(() => {
+ this.load(true).then(this.refreshByTime)
+ }, 30000)
+ }
+
+ componentDidMount() {
+ this.refreshByTime()
+ }
+
+ onPortalClick = (guid: string, coords: LatLng) => {
+ this.setState({ selectedPortal: { guid, coords } })
+ }
+
+ onOpenPortal = (guid: string, coords: LatLng) => {
+ const navigateAction = NavigationActions.navigate({
+ routeName: 'Portal',
+ params: { guid, coords },
+ });
+ this.props.navigation.dispatch(navigateAction);
+ }
+
+ render() {
+ if (!this.state.user) {
+ return <View style={styles.spinnerContainer}><ActivityIndicator size={'large'} /></View>
+ }
+ const camera = {
+ center: this.state.user,
+ altitude: 1000,
+ heading: 0,
+ pitch: 30,
+ zoom: 15,
+ }
+ const goToMe = () => {
+ this.map.animateToCoordinate(this.state.user)
+ }
+ const refresh = () => {
+ setImmediate(() => this.load(true))
+ }
+ return (
+ <>
+ <MapView
+ ref={r => (r != null) ? this.map = r : null}
+ style={styles.container}
+ initialRegion={{ ...this.state.user, latitudeDelta: 0.002, longitudeDelta: 0.002 }}
+ onRegionChangeComplete={this.onRegionChange}
+ showsCompass={false}
+ showsScale
+ showsUserLocation
+ showsMyLocationButton
+ loadingEnabled
+ type={'hybrid'}
+ >
+ <MapObjects onPortalClick={this.onPortalClick} zoom={this.state.zoom} />
+ {this.state.selectedPortal && <Marker coordinate={this.state.selectedPortal.coords} />}
+ </MapView>
+ <MapOverlay
+ goToMe={goToMe}
+ refresh={refresh}
+ loading={this.props.entities.loading}
+ selectedPortal={this.state.selectedPortal}
+ onOpenPortal={this.onOpenPortal}
+ />
+ </>
+ );
+ }
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ },
+ spinnerContainer: {
+ flex: 1,
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ panel: {
+ flex: 1,
+ backgroundColor: '#fff',
+ position: 'relative',
+ borderTopLeftRadius: 20,
+ borderTopRightRadius: 20,
+ //ios
+ shadowOpacity: 0.3,
+ shadowRadius: 3,
+ shadowOffset: {
+ height: 0,
+ width: 0
+ },
+ //android
+ elevation: 1
+ },
+ panelHeader: {
+ height: 40,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ dash: {
+ backgroundColor: 'rgba(200,200,200,0.9)',
+ height: 6,
+ width: 30,
+ borderRadius: 3
+ }
+});
+
+export default connect({ 'entities': 'entities' }, actions)(Map) \ No newline at end of file
diff --git a/src/Components/MapObjects.tsx b/src/Components/MapObjects.tsx
new file mode 100644
index 0000000..1d2fb12
--- /dev/null
+++ b/src/Components/MapObjects.tsx
@@ -0,0 +1,90 @@
+import React, { ReactChild } from 'react';
+import { View, StyleSheet, Text } from 'react-native';
+import { Polyline, Polygon, Marker, Region } from 'react-native-maps';
+import Icon from 'react-native-vector-icons/FontAwesome';
+import { Link, Portal, Field } from '../Api/types';
+import { connect } from 'react-redux';
+import { COLORS, COLORS_LVL } from '../colors';
+
+const fillPortalColor = { "R": "rgba(2, 140, 227, 0.5)", "E": "rgba(38, 205, 30, 0.5)", "N": "rgba(139, 0, 255, 0.5)" }
+// const fillPortalColor = { "R": COLORS[1], "E": COLORS[2], "N": COLORS[0] }
+const fractColor = { "R": "#028ce3", "E": "#26cd1e", "N": "#8b00ff" }
+// const fractColor = { "R": COLORS[1], "E": COLORS[2], "N": COLORS[0] }
+const fieldColor = { "R": "rgba(2, 140, 227, 0.1)", "E": "rgba(38, 205, 30, 0.1)", "N": "rgba(139, 0, 255, 0.1)" }
+
+type Props = {
+ portals: { [guid: string]: Portal }
+ links: { [guid: string]: Link }
+ fields: { [guid: string]: Field }
+ onPortalClick: Function
+ zoom: number
+}
+
+const MapObjects = (props: Props) => {
+ const renderPortal = Object.keys(props.portals).map(guid => drawPortal(guid, props.portals[guid], props.zoom, props.onPortalClick))
+ const renderField = Object.keys(props.fields).map(guid => drawField(guid, props.fields[guid]))
+ const renderLink = Object.keys(props.links).map(guid => drawLink(guid, props.links[guid]))
+
+ if (props.zoom <= 14) {
+ return [...renderField, ...renderLink]
+ } else {
+ return [...renderField, ...renderLink, ...renderPortal]
+ }
+}
+const drawPortal = (guid: string, entity: Portal, zoom: number, onPortalClick: Function) => {
+ const size = (zoom) * 1.5
+ return (<Marker
+ key={guid}
+ coordinate={entity.coords}
+ onPress={() => onPortalClick(guid, entity.coords)}
+ >
+ {/* <Icon name={entity.fraction == "N" ? "circle-o" : "circle"} color={fillPortalColor[entity.fraction]} size={size} /> */}
+ <View style={{
+ borderWidth: 2,
+ height: size,
+ width: size,
+ borderRadius: size / 2,
+ borderColor: COLORS_LVL[entity.level],
+ backgroundColor: fillPortalColor[entity.fraction],
+ justifyContent: 'center',
+ alignItems: 'center',
+ }}>
+ <Text style={{ fontWeight: 'bold' }}>{entity.level}</Text>
+ </View>
+ </Marker>);
+}
+const drawField = (guid: string, entity: Field) => {
+ return <Polygon
+ key={guid}
+ coordinates={entity.coords}
+ fillColor={fieldColor[entity.fraction]}
+ strokeColor={fieldColor[entity.fraction]}
+ strokeWidth={StyleSheet.hairlineWidth}
+ />
+}
+const drawLink = (guid: string, entity: Link) => {
+ return <Polyline
+ key={guid}
+ coordinates={entity.coords}
+ strokeColor={fractColor[entity.fraction]}
+ strokeWidth={1}
+ />
+}
+
+
+const styles = StyleSheet.create({
+
+});
+
+export default connect((store) => {
+ const display = store.entities.display
+ const portals = {}
+ const fields = {}
+ const links = {}
+ display.portals.forEach(guid => { portals[guid] = store.entities.portals[guid] })
+ display.fields.forEach(guid => { fields[guid] = store.entities.fields[guid] })
+ display.links.forEach(guid => { links[guid] = store.entities.links[guid] })
+ return {
+ portals, fields, links
+ }
+}, {})(MapObjects) \ No newline at end of file
diff --git a/src/Components/MapOverlay.tsx b/src/Components/MapOverlay.tsx
new file mode 100644
index 0000000..30160c9
--- /dev/null
+++ b/src/Components/MapOverlay.tsx
@@ -0,0 +1,91 @@
+import React from 'react';
+import { StyleSheet, View, Text, GestureResponderEvent, ActivityIndicator, Linking } from 'react-native';
+import { FontAwesome } from '@expo/vector-icons';
+import { getStatusBarHeight } from '../helper';
+import { LatLng } from '../Api/interfaces';
+
+type Props = {
+ goToMe: (e: GestureResponderEvent) => void;
+ refresh: (e: GestureResponderEvent) => void;
+ onOpenPortal: (guid: string, coords: LatLng) => void;
+ loading: Number;
+ selectedPortal: any;
+}
+
+export default (props: Props) => (
+ <View style={styles.overlay}>
+ <View style={styles.button}>
+ <FontAwesome.Button
+ name={'crosshairs'}
+ onPress={props.goToMe}
+ iconStyle={styles.icon}
+ color={"#000"}
+ size={24}
+ backgroundColor={"#e3e3e3"}
+ />
+ </View>
+ <View style={styles.button}>
+ <FontAwesome.Button
+ name={'refresh'}
+ onPress={props.refresh}
+ iconStyle={styles.icon}
+ color={"#000"}
+ size={24}
+ backgroundColor={"#e3e3e3"}
+ />
+ </View>
+ {props.selectedPortal ? (
+ <>
+ <View style={styles.button}>
+ <FontAwesome.Button
+ name={'info'}
+ onPress={() => {
+ props.onOpenPortal(props.selectedPortal.guid, props.selectedPortal.coords)
+ }}
+ iconStyle={styles.icon}
+ color={"#000"}
+ size={24}
+ backgroundColor={"#e3e3e3"}
+ />
+ </View>
+ <View style={styles.button}>
+ <FontAwesome.Button
+ name={'location-arrow'}
+ onPress={() => Linking.openURL(`geo:${props.selectedPortal.coords.latitude},${props.selectedPortal.coords.longitude}`)}
+ iconStyle={styles.icon}
+ color={"#000"}
+ size={24}
+ backgroundColor={"#e3e3e3"}
+ />
+ </View>
+ </>
+ ) : null}
+
+ <View style={styles.button}>
+ <Text>{props.loading}</Text>
+ </View>
+ {props.loading > 0 ? (
+ <View style={styles.loader}>
+ <ActivityIndicator color={"#000"} />
+ </View>
+ ) : null}
+ </View>
+);
+
+const styles = StyleSheet.create({
+ overlay: {
+ position: 'absolute',
+ top: 8 + getStatusBarHeight(true),
+ right: 8,
+ },
+ button: {
+ margin: 8,
+ },
+ icon: {
+ marginRight: 0,
+ },
+ loader: {
+ margin: 8,
+ paddingHorizontal: 8,
+ }
+}); \ No newline at end of file
diff --git a/src/Components/PortalPanel.tsx b/src/Components/PortalPanel.tsx
new file mode 100644
index 0000000..6cf19c2
--- /dev/null
+++ b/src/Components/PortalPanel.tsx
@@ -0,0 +1,70 @@
+import React, { Component, PureComponent } from 'react';
+import { StyleSheet, View, Text, GestureResponderEvent, ActivityIndicator } from 'react-native';
+// import { Button } from 'react-native-vector-icons/FontAwesome';
+import Reactotron from 'reactotron-react-native'
+import { getStatusBarHeight } from '../helper';
+import { connect } from 'react-redux';
+import actions from '../Actions/actions';
+import { Portal } from '../Api/types';
+import { bindActionCreators } from 'redux';
+
+type Props = {
+ guid: string
+ portal?: Portal
+}
+
+class PortalPanel extends PureComponent<Props> {
+ static navigationOptions = ({ navigation }) => {
+ return {
+ title: navigation.getParam('title', 'Загрузка...'),
+ };
+ };
+ componentWillMount() {
+ this.props.navigation.setParams({title: this.props.portal.name})
+ }
+ componentDidMount() {
+ this.props.getPortalDetails(this.props.guid)
+ }
+ componentWillReceiveProps(next: Props) {
+ if (next.guid != this.props.guid) {
+ this.props.navigation.setParams({title: this.props.portal.name})
+ this.props.getPortalDetails(next.guid)
+ }
+ }
+ render() {
+ const { portal } = this.props
+ if (!portal) {
+ return <ActivityIndicator />
+ }
+ return (
+ <View style={styles.overlay}>
+ <Text style={styles.title}>{portal.name}</Text>
+ <Text style={styles.subtitle}>Уровeнь: {portal.level}, здоровье: {portal.power}</Text>
+ <Text>{JSON.stringify(this.props)}</Text>
+ </View>
+ );
+ }
+}
+
+export default connect((store, props: Props) => {
+ const guid = props.navigation.getParam('guid')
+ return {
+ portal: store.entities.portals[guid],
+ guid
+ }
+}, (dispatch) => bindActionCreators(actions, dispatch))(PortalPanel)
+
+const styles = StyleSheet.create({
+ overlay: {
+ flex: 1,
+ padding: 8,
+ },
+ title: {
+ fontWeight: 'bold',
+ fontSize: 22,
+ },
+ subtitle: {
+ fontWeight: 'normal',
+ fontSize: 18,
+ }
+}); \ No newline at end of file
diff --git a/src/Main.js b/src/Main.js
new file mode 100644
index 0000000..5a67c64
--- /dev/null
+++ b/src/Main.js
@@ -0,0 +1,35 @@
+import React, { Component } from 'react';
+import { Platform, StyleSheet, Text, View } from 'react-native';
+import { connect } from 'redux-su';
+import Login from './Components/Login';
+import Map from './Components/Map';
+import PortalPanel from './Components/PortalPanel';
+import { createStackNavigator, createAppContainer } from 'react-navigation';
+
+const AppNavigator = createStackNavigator({
+ Map: {
+ screen: Map,
+ navigationOptions: {
+ header: null,
+ }
+ },
+ Portal: {
+ screen: PortalPanel,
+ navigationOptions: () => ({
+ headerMode: 'float',
+ headerBackTitle: null
+ })
+ },
+});
+const AppContainer = createAppContainer(AppNavigator)
+class App extends Component {
+ render() {
+ return (
+ <View style={{ flex: 1 }}>
+ {this.props.auth.user ? <AppContainer /> : <Login />}
+ </View>
+ );
+ }
+}
+
+export default connect({ 'auth': 'auth' }, {})(App) \ No newline at end of file
diff --git a/src/Store/store.ts b/src/Store/store.ts
new file mode 100644
index 0000000..0929882
--- /dev/null
+++ b/src/Store/store.ts
@@ -0,0 +1,63 @@
+import { createStore, combineReducers, applyMiddleware } from 'redux';
+import thunk from 'redux-thunk';
+import { createReducer } from 'redux-su';
+import { composeWithDevTools } from 'redux-devtools-extension';
+import { Portal, Link, Field } from '../Api/types';
+
+type Display = {
+ portals: string[],
+ fields: string[],
+ links: string[]
+}
+
+const reducers = {
+ 'auth': createReducer({
+ 'authSet': (store: any, action: { v: string, csrf: string, user: any }) => ({ v: action.v, csrf: action.csrf, user: action.user, loading: false }),
+ 'authLoad': (store: any, action: { loading: boolean }) => ({ ...store, loading: action.loading })
+ }, { v: "", csrf: "", user: null, loading: false }),
+ 'entities': createReducer({
+ 'portalSet': (store: any, action: { guid: string, portal: Portal }) =>
+ ({ ...store, portals: { ...store.portals, [action.guid]: action.portal } }),
+ 'linkSet': (store: any, action: { guid: string, link: Link }) =>
+ ({ ...store, links: { ...store.links, [action.guid]: action.link } }),
+ 'fieldSet': (store: any, action: { guid: string, field: Field }) =>
+ ({ ...store, fields: { ...store.fields, [action.guid]: action.field } }),
+ 'portalsSet': (store: any, action: { portals: Portal }) => {
+ const portals = store.portals
+ Object.keys(action.portals).forEach(guid => {
+ const portal: Portal = portals[guid]
+ const newPortal: Portal = action.portals[guid]
+ if (!portal) {
+ portals[guid] = newPortal
+ return
+ }
+ portals[guid] = extend(portal, newPortal)
+ })
+ return { ...store, portals }
+ },
+ 'linksSet': (store: any, action: { links: { [guid: string]: Link } }) =>
+ ({ ...store, links: { ...store.links, ...action.links } }),
+ 'fieldsSet': (store: any, action: { fields: { [guid: string]: Field } }) =>
+ ({ ...store, fields: { ...store.fields, ...action.fields } }),
+ 'entitiesLoad': (store: any, action: { loading: number }) =>
+ ({ ...store, loading: action.loading }),
+ 'entitiesDisplay': (store: any, action: { display: Display }) =>
+ ({ ...store, display: action.display }),
+
+ }, { portals: {}, fields: {}, links: {}, loading: 0, display: { portals: [], fields: [], links: [] } })
+}
+
+function extend(obj: any, src: any) {
+ for (var key in src) {
+ if (src.hasOwnProperty(key) && !!src[key]) {
+ obj[key] = src[key];
+ }
+ }
+ return obj;
+}
+
+const store = createStore(
+ combineReducers(reducers),
+ composeWithDevTools(applyMiddleware(thunk))
+)
+export default store \ No newline at end of file
diff --git a/src/colors.ts b/src/colors.ts
new file mode 100644
index 0000000..13b5e4f
--- /dev/null
+++ b/src/colors.ts
@@ -0,0 +1,3 @@
+export const COLORS = ['#FF6600', '#0088FF', '#03DC03']; // none, res, enl
+export const COLORS_LVL = ['#000', '#FECE5A', '#FFA630', '#FF7315', '#E40000', '#FD2992', '#EB26CD', '#C124E0', '#9627F4'];
+export const COLORS_MOD = { VERY_RARE: '#b08cff', RARE: '#73a8ff', COMMON: '#8cffbf' }; \ No newline at end of file
diff --git a/src/helper.js b/src/helper.js
new file mode 100644
index 0000000..3bdbf7e
--- /dev/null
+++ b/src/helper.js
@@ -0,0 +1,46 @@
+import { Dimensions, Platform, StatusBar } from 'react-native';
+
+
+export function isIphoneX() {
+ const dimen = Dimensions.get('window');
+ return (
+ Platform.OS === 'ios' &&
+ !Platform.isPad &&
+ !Platform.isTVOS &&
+ ((dimen.height === 812 || dimen.width === 812) || (dimen.height === 896 || dimen.width === 896))
+ );
+}
+
+export function ifIphoneX(iphoneXStyle, regularStyle) {
+ if (isIphoneX()) {
+ return iphoneXStyle;
+ }
+ return regularStyle;
+}
+
+export function getStatusBarHeight(safe) {
+ return Platform.select({
+ ios: ifIphoneX(safe ? 44 : 30, 20),
+ android: StatusBar.currentHeight
+ });
+}
+
+export function getBottomSpace() {
+ return isIphoneX() ? 34 : 0;
+}
+
+export function timeConverter(UNIX_timestamp) {
+ var a = new Date(UNIX_timestamp * 1000);
+ var year = a.getFullYear();
+ var month = a.getMonth() + 1;
+ var date = a.getDate();
+ var hour = a.getHours();
+ var min = a.getMinutes();
+ var sec = a.getSeconds();
+ var time = formatInt(hour) + ':' + formatInt(min) + ':' + formatInt(sec) + ' ' + formatInt(date) + '.' + formatInt(month) + '.' + year;
+ return time;
+}
+
+function formatInt(num) {
+ return ("00" + num).slice(-2)
+} \ No newline at end of file