본문 바로가기

Function

<화물차를 쉬게 하라> - 1. DTG 데이터의 처리

이 글에서는 시사IN 기사 <화물차를 쉬게 하라>를 위해 했던 작업을 다룬다.

 

 

시사IN x VWL 특별기획 화물차를 쉬게 하라

DTG 데이터로 본 365일 24시간의 노동

truck.sisain.co.kr

첫번째 글로, DTG 데이터를 처리하는  R 코드와 시각화에 사용된 자바스크립트 코드일부를 기술해본다.

 

 

 

DTG 데이터는 용량이 매우 크다.

20여개의 항목들을 차량마다 1초 단위로 기록하는데, 한행 한행마다 200자 넘는 텍스트로 구성되어 있다. 

예를 들어 교통안전공단에 제출된 전국 화물차 DTG 한 달 치의 용량은 333GB정도가 된다. 물론 압축을 했을 때 그렇고 압축을 풀면 4.4TB 정도로 커진다.

 

사실 아예 빅데이터 시스템을 갖춘 상황이라면 4.4TB는 그렇게 큰 용량이 아니다. 그런데 이 데이터를 PC에서 분석하려고 할 때 그리 수월하지는 않다. 생각나는 가장 빠른 방법은 데이터들을 바이너리로 만들어 c++에서 struct 로 곧바로 읽어낸 후 병렬로 처리하는 방법이다. 예전에 교통카드 트립체인 데이터는 그런 방식으로 처리해서 분석해본 적이 있다. 그렇지만 처리하는 코드를 짜는 시간이 아무래도 오래 걸렸기 때문에 이번에는 R로 시도해보았다. 

 

일단 무슨 처리를 하든 데이터를 저장장치에서 읽으면서 작업을 해야 하기 때문에, 속도가 빠른 SSD가 있으면 좋다. 램은 꽉 채워 쓰진 않았던 것 같은데, 128GB에서 작업했다. 중간중간에 병렬 연산도 하기 때문에 코어 수도 많으면 좋다. 그러나 하나의 SSD에서 데이터를 읽을 경우, 통상적으로 8개 이상의 코어를 사용하면 속도 증가분이 별로 없기 때문에 코어도 아주 많이는 필요 없다.

 

R을 사용할 때 무조건 data.table을 사용해야 한다. data.frame은 점점 성능이 좋아지는 것 같기는 하지만, data.table에는 비할 바가 아니다. data.table는 데이터를 레퍼런스 형식으로 취급해서 처리할 때 램의 점유율도 적고 빠르다. data.frame을 사용할 경우 11GB 정도 데이터를 읽어서 aggregate를 하면 64GB 램이 가득 찼던 기억도 있다.

 

DTG 데이터가 쉽게 접할 수 있는 데이터는 아니지만, 비슷한 종류의 작업을 할 때 누군가 참고로 하겠지, 라는 생각으로 기록을 남겨본다. 샘플이라도 데이터를 올려놓을 수는 없기 때문에, 전체를 재현하지는 못한다. 여기서는 팁이 될 만한 주요한 코드들만 옮겨적어보겠다. 어차피 분석하는 코드는 목적에 따라 달리질 것이므로.

 

1. 데이터 읽어서 운전자 프로필만 추출하기

library(doParallel)
memory.limit()

sourceFolder <- paste0("A:\\work\\202208_화물차DTG\\00_zip\\")
fileNames = dir(sourceFolder, pattern = "DTG-r-*.*gz")
targetFolder <- paste0("W:\\work\\202207_화물차\\02_운전자프로필\\")

myCluster <- parallel::makePSOCKcluster(6)

rcppinit <- function() {
  library(Rcpp)
  library(data.table)
  library(dplyr)
  library(rgdal)
  library(R.utils)
}

clusterCall(myCluster, rcppinit)
doParallel::registerDoParallel(myCluster)

foreach(
  i = c(1:length(fileNames))
) %dopar% {  
  

  fileName <- paste0(sourceFolder,fileNames[i])
  
  system.time(raw <- fread(fileName,
                           #quote="\"", 
                           encoding="UTF-8",
                           select = c(2:7), 
                           colClasses = list(character = c(2,3,5,6,7),
                                             integer=c(4)
                                             #numeric = c(13,14)
                           ),
                           col.names = c('dtgType', 'carProfile','type','carid','driverid', 'drivercode'),
                           sep = "|", header = FALSE, stringsAsFactors = FALSE,
                           nThread = 7, verbose = F, 
                           tmpdir = "a:/temp/R/" #gz를 읽을 때 tempdir을 사용하므로, 가장 빠른 디스크로 지정해준다.
  )
  )
  
  data <-unique(raw, by = c('dtgType', 'carProfile', 'type', 'carid', 'driverid', 'drivercode'))
  
  
  writeName <- paste0(targetFolder,substr(fileNames[i],10,11),substr(fileNames[i],18,18),"_id.tsv")
  fwrite(data, file = writeName, 
         sep = "\t", row.names = FALSE, col.names = TRUE)
  print(fileName)
  
  gc()
  
}
stopCluster(myCluster)

gc()

key 값으로 사용할 수 있는 차량번호나 trip의 고유번호 같은 것들은 매우 긴 문자열로 이루어져 있다. 이 문자열들을 integer 의 일련번호로 만들기 위해 우선 프로필로 사용할 수 있는 고유값들을 추출했다.

 

여기서 중요한 부분은 fread로 읽되, 필요한 행만 지정해서 읽는 부분이다. 실제로 읽는 속도가 많이 줄어든다. 그리고 읽을 때 아예 위와 같이 자료형을 지정해주면 다시 자료형을 변환하는 번거로움이 없어서 좋다.

 

fread의 옵션 중에 tmpdir 부분도 중요하다.

333GB를 다 풀어놓고 4.4TB로 만들어 작업하면 좋긴 하지만, 당시 저장장치에 그럴만한 공간이 없었다. fread의 장점 중 하나는 gz 압축 파일을 그대로 읽을 수 있다는 점이기 때문에 333GB 원본을 놓고 그대로 읽어서 작업했다. 물론 압축 파일을 그대로 읽어내는 마법이 존재하는것은 아니고, 잠시 임시 폴더에 압축을 푼 후에 읽게 된다.

이 때 압축을 풀어놓을 임시 폴더를 지정할 수 있는데, 그 부분이 바로 tmpdir이다. 이 경로는 자신의 저장장치에서 가장 빠른 드라이브를 지정해주어야 한다. 위의 코드를 보면 6개 코어에서 일시에 압축을 풀어서 다시 읽어야 하기 때문에 HDD 같은 경우에는 아주 심각하게 속도가 느려진다. 자신의 시스템에서 몇 개 코어가 최적인지 시험해보고 전체 데이터를 돌리는 것을 권장한다. 

 

한 파일을 읽어서 unique  값들을 추출하면 그때그때 저장해준다. 바로 다음 과정에서 다시 읽어서 합칠 예정이다.

 

2. 다시 읽어서 합치면서 일련번호 부여하기

#이제 하나의 파일로 합치기

sourceFolder <- paste0("W:\\work\\202207_화물차\\02_운전자프로필\\")
fileNames = dir(sourceFolder, pattern = "*.tsv")
targetFolder <- paste0("W:\\work\\202207_화물차\\02_운전자프로필\\")

data <- data.table()

for (fileName in fileNames) {
  
  fileN <- paste0(sourceFolder,fileName)
  
  system.time(raw <- fread(fileN,
                           #quote="\"", 
                           encoding="UTF-8",
                           select = c(1:6), 
                           colClasses = list(character = c(1,2,4,5,6),
                                             integer=c(3)                                            
                           ),                          
                           sep = "\t", header = TRUE, stringsAsFactors = FALSE                        
  )
  )
  
  data <- rbindlist(list(data, raw))
  print(fileN)
}

data <-unique(data, by = c('dtgType', 'carProfile', 'type', 'carid', 'driverid', 'drivercode'))

data <- data %>% select(carid, drivercode, carProfile, dtgType, type, driverid)


#car id를 일련번호로 바꾼다
data[, id := .I]

writeName <- paste0(targetFolder, "carDriverProfile.tsv")
fwrite(data, file = writeName, 
       sep = "\t", row.names = FALSE, col.names = TRUE)

 

앞에서 저장한 데이터를 읽어서 합친다. 이 때 data[, id:=.I] 명령어로 일련번호를 붙인다. 빠른 처리를 위해 문자열을 key값으로 사용하는 것은 최우선적으로 피한다. 20억개 이내라면 integer로 변환해서 처리하면 훨씬 빨라진다.

 

3. tripid 모으기

 

 이번에는 tripid를 모아보자. 물론 한데 모아서 integer로 된 key 값을 부여할 용도다.

tripid는 C99가123422041210544700 과 같은 형식으로 비식별화된 차량 번호에 해당 trip을 시작한 연월일시가 붙어 있다. 여기서는 2022년 4월 12일 10시 54분 47초다. 시각 정보는 다른 항목에도 있으므로 저 문자열을 다 살릴 필요는 없다. 

gc()
library(doParallel)


sourceFolder <- paste0("W:\\work\\202207_화물차\\01_raw\\")
fileNames = dir(sourceFolder, pattern = ".*gz")
targetFolder <- paste0("W:\\work\\202207_화물차\\03_tripid\\")

myCluster <- parallel::makePSOCKcluster(6)

rcppinit <- function() {
  #library(doParallel)
  library(Rcpp)
  library(data.table)
  library(dplyr)
  library(rgdal)
  library(R.utils)
}

clusterCall(myCluster, rcppinit)
doParallel::registerDoParallel(myCluster)

foreach(
  i = c(1:length(fileNames))
) %dopar% {  
  

  fileName <- paste0(sourceFolder,fileNames[i])
  
  system.time(raw <- fread(fileName,
                           #quote="\"", 
                           encoding="UTF-8",
                           select = c(1), 
                           colClasses = list(character = c(1)                           
                           ),                          
                           sep = "\t", header = TRUE, stringsAsFactors = FALSE,
                           nThread = 5, verbose = F, 
                           tmpdir = "a:/temp/R/" #gz를 읽을 때 tempdir을 사용하므로, 가장 빠른 디스크로 지정해준다.
  )
  )
  
  data <-unique(raw, by = c('tripid'))
  
  
  writeName <- paste0(targetFolder,substr(fileNames[i],1,3),substr(fileNames[i],18,18),"_tripid.tsv")
  fwrite(data, file = writeName, 
         sep = "\t", row.names = FALSE, col.names = TRUE)
  print(fileName)
  
  gc()
  
}
stopCluster(myCluster)

gc()

간단하게 첫번째 행만 읽고 unique 값을 추출해서 저장했다. 아까와 같은 방식이다.

 

이제 하나의 파일로 합치면서 일련번호를 부여한다. 아까와 같은 방식이다.

#이제 하나의 파일로 합치기

sourceFolder <- paste0("W:\\work\\202207_화물차\\03_tripid\\")
fileNames = dir(sourceFolder, pattern = "*.tsv")
targetFolder <- paste0("W:\\work\\202207_화물차\\03_tripid\\")

data <- data.table()

for (fileName in fileNames) {
  
  fileN <- paste0(sourceFolder,fileName)
  
  system.time(raw <- fread(fileN,
                           #quote="\"", 
                           encoding="UTF-8",
                           select = c(1), 
                           colClasses = list(character = c(1)                                      
                           ),                          
                           sep = "\t", header = TRUE, stringsAsFactors = FALSE
                           #nThread = 7, verbose = F, 
                           #tmpdir = "a:/temp/R/" #gz를 읽을 때 tempdir을 사용하므로, 가장 빠른 디스크로 지정해준다.
  )
  )
  
  data <- rbindlist(list(data, raw))
  print(fileN)
}


data <-unique(data, by = c('tripid'))

#car id를 일련번호로 바꾼다
setDT(data)
data[, tid := .I]


writeName <- paste0(targetFolder, "tripIDs.tsv")
fwrite(data, file = writeName, 
       sep = "\t", row.names = FALSE, col.names = TRUE)

 

4. 원본 데이터를 일련변호 key 값으로 다시 정리하기

 

주요한 key 값들에 integer 형식의 일련번호를 부여하였으므로, 이제 다시 원본 파일을 읽어서 해당 일련번호들로 치환해준다.

 

##carid와 tripid에 일련번호가 부여되었으므로, 이제 raw data를 아래와 같이 재정리
##제일 처음에 split 한 파일을 이용

#tid	id	time	      x	        y
#1	28966	1649760887	928158.38	1887904.64
#1	28966	1649760888	928158.38	1887904.64
#1	28966	1649760889	928158.38	1887904.64
#1	28966	1649760890	928158.66	1887905.63
#1	28966	1649760891	928158.66	1887905.63
#1	28966	1649760892	928158.66	1887905.63


### tripDist.tsv 파일에는 trip id별로 진행 거리를/시종점 기록한다. 하나의 trip당 시점 종점 총 두줄씩 기록한다.
#tid	id	distsum	dist	time	      x	        y
#1	28966	225532	64	  1649760887	928158.38	1887904.64
#1	28966	225596	64	  1649807999	928123.63	1887920.13
#2	28967	99974	  35	  1649030400	928255.56	1887809.18
#2	28967	100009	35	  1649116799	928109.53	1887958.19
#3	28967	100252	25	  1649726294	928121.72	1887884.2
#3	28967	100277	25	  1649807999	928371.59	1887446.85



library(data.table)
library(dplyr)
gc()
rm(list=ls())
gc()
targetFolder <- paste0("W:\\work\\202207_화물차\\02_운전자프로필\\")
fileName <- paste0(targetFolder, "carDriverProfile.tsv")
carProfile <- fread(fileName, 
                    encoding="UTF-8",
                    select = c(1:7), 
                    colClasses = list(character = c(1,2,3,4,6),
                                      integer=c(5,7)
                                      #numeric = c(13,14)
                    ),
                    #col.names = c('carid', 'driver', 'id'),
                    sep = "\t", header = TRUE, stringsAsFactors = FALSE)


targetFolder <- paste0("W:\\work\\202207_화물차\\03_tripid\\")
fileName <- paste0(targetFolder, "tripIDs.tsv")
tripIDs <- fread(fileName, 
                 encoding="UTF-8",
                 select = c(1:2), 
                 colClasses = list(character = c(1),
                                   integer=c(2)
                                   #numeric = c(13,14)
                 ),
                 #col.names = c('carid', 'driver'),
                 sep = "\t", header = TRUE, stringsAsFactors = FALSE)



library(doParallel)
memory.limit()

sourceFolder <- paste0("A:\\work\\202208_화물차DTG\\00_zip\\")
fileNames = dir(sourceFolder, pattern = "DTG-r-*.*gz")
targetFolder <- paste0("W:\\work\\202207_화물차\\01_raw\\")
targetFolder2 <- paste0("W:\\work\\202207_화물차\\08_tripDist\\")


myCluster <- parallel::makePSOCKcluster(3)

rcppinit <- function() {
  #library(doParallel)
  library(Rcpp)
  library(data.table)
  library(dplyr)
  library(rgdal)
  library(R.utils)
  Rcpp::sourceCpp("D:/temp/yymmddConverter.cpp")
  print(ymdhmsVWL2('1803310200000'))
}

setDTthreads(30)
clusterCall(myCluster, rcppinit)
doParallel::registerDoParallel(myCluster)

foreach(
  i = c(1:length(fileNames))
) %dopar% {  
  
  #i=1
  fileName <- paste0(sourceFolder,fileNames[i])
  
  system.time(raw <- fread(fileName,
                           #quote="\"", 
                           encoding="UTF-8",
                           select = c(1,2,3,4,5,6,7,9,13,14,20), 
                           colClasses = list(character = c(1,2,3,5,6,7,20),
                                             integer=c(4,9),
                                             numeric = c(13,14)),
                           col.names = c('tripid', 'dtgType', 'carProfile', 'type','carid','driverid','drivercode','distsum','lon','lat','time'),
                           sep = "|", header = FALSE, stringsAsFactors = FALSE,
                           nThread = 7, verbose = F, 
                           tmpdir = "a:/temp/R/" #gz를 읽을 때 tempdir을 사용하므로, 가장 빠른 디스크로 지정해준다.
  )
  )
  
  
  raw <- merge(raw, carProfile, by = c('carid','drivercode','carProfile','dtgType','type','driverid'))
  raw <- merge(raw, tripIDs, by ='tripid')
  
    
  ## 좌표 변환
  raw[, ':='(lon = lon/1000000,
             lat = lat/1000000)]
  raw <- raw[lon>120 & lon<133 & lat>30 & lat<39]
  from.crs <- CRS(SRS_string = "EPSG:4326")
  to.crs <- CRS(SRS_string = "EPSG:5179")
  system.time(converted <- raw %>% select(x=lon, y=lat) %>% #coordinates(.) %>%
                SpatialPoints(., proj4string=from.crs) %>%
                spTransform( ., to.crs) 
  )
  converted <- as.data.table(converted)
  converted[, ':='(x = round(x, digits=2),
                   y = round(y, digits=2))]
  raw <-  cbind(raw, converted)
  
  
  #날짜변경
  #print("1")
  raw[, time := ymdhmsVWL2(time)]
  
  
  #이동거리만 추출
  heads <- raw[, .SD[c(1, .N)], by = tid]
  tripDist <- heads[, dist := max(distsum)-min(distsum), by =tid]
  tripDist <- subset(tripDist, select = c(tid, id, distsum, dist, time, x, y))
  
  fwrite(tripDist, file = paste0(targetFolder2, substr(fileNames[i],10,11),substr(fileNames[i],18,18),"_tripDist.tsv"), sep="\t")
    
  raw <- subset(raw, select = c(tid, id, time, x, y))  
  
  writeName <- paste0(targetFolder,substr(fileNames[i],10,11),substr(fileNames[i],18,18),"_truck.tsv")
  fwrite(raw, file = writeName, 
         sep = "\t", row.names = FALSE, col.names = TRUE)  
  
  gzip(writeName, destname = sprintf("%s.gz", writeName))
  
  print(fileName)
  gc()
}
stopCluster(myCluster)
gc()

 

 

위에서 등장한 yymmddConverter.cpp 는 아래와 같은 rcpp파일이다. 날짜 변환을 빠르게 수행하려고 만들었다.

보통, 문자열 혹은 date 형식으로 된 날짜시각을 1970년 1월 1일을 기준으로 초단위 환산한 unixtime으로 변환해서 쓰는 편이다. 아무래도 integer 기반 데이터이므로 연산이 빠를 수 밖에 없다.

//처음 버젼에서 오류 수정(입력이 몇개든간에 단 1개만 처리하고 리턴하도록 되어 있었음
//직전 달까지 날짜 수를 1개월 단위로 더해서 계산하던 것을 누적값을 담은 배열로 한번에 처리
//openmp 도입하여 멀티코어 활용하도록 수정

#include <Rcpp.h>
#include <vector>
#include <omp.h>


// [[Rcpp::plugins("cpp11")]]
using std::vector;
using namespace Rcpp;
using std::string;
using std::cout;
using std::endl;

// [[Rcpp::plugins(openmp)]]

//unsigned int daysOfMonth[12] = { 31,28,31,30,31,30,31,31,30,31,30,31 };
unsigned int daysUntilLastMonth[12] = { 0,31,59,90,120,151,181,212,243,273,304,334 };



// [[Rcpp::export]]
IntegerVector ymdhmsVWL2(StringVector ymdhms) { 
  
  unsigned int len = ymdhms.size();
  IntegerVector result(len);
  
  
#pragma omp parallel for  
  for (unsigned int i=0  ; i<len ; i++)
  {
    unsigned int year = 2000 + ymdhms[i][0] * 10 + ymdhms[i][1] - '0' * 11;
    unsigned int month = ymdhms[i][2] * 10 + ymdhms[i][3] - '0' * 11;
    unsigned int day = ymdhms[i][4] * 10 + ymdhms[i][5] - '0' * 11;
    
    
    day += daysUntilLastMonth[month - 1]; 
    if (month > 2) {
      if (year % 400 == 0 || (year % 4 == 0 && year % 100 != 0)) { 
        day += 1;
      }
    }
    day += 365 * (year - 1);
    day += ((year - 1) / 4) - ((year - 1) / 100) + ((year - 1) / 400); 
    
    
    unsigned int secFrom19700101 = 0; 
    
    secFrom19700101 += (day - 719163) * 86400; 
    
    unsigned int hh = ymdhms[i][6] * 10 + ymdhms[i][7] - '0' * 11;
    unsigned int  mm = ymdhms[i][8] * 10 + ymdhms[i][9]  - '0' * 11;
    unsigned int  ss = ymdhms[i][10] * 10 + ymdhms[i][11]  - '0' * 11;
    
    secFrom19700101 += hh * 3600 + mm * 60 + ss;
    
    result[i] = secFrom19700101;
    
  }
  
  return result;
  
}

 

 

다시 R 코드로 돌아가면,  우선 위경도 좌표계를 EPSG:5179 형식으로 변환했다. rgdal 라이브러리를 사용했다.

그리고 trip별 이동거리를 계산했다. data.table 문법에 익숙하지 않은 사람들은 아래의 cheat sheet를 참고하면 좋다.

datatable.pdf를 찾아보자.

 

GitHub - rstudio/cheatsheets: RStudio Cheat Sheets

RStudio Cheat Sheets. Contribute to rstudio/cheatsheets development by creating an account on GitHub.

github.com

 

 

여기까지 해서 01_raw 폴더에 이제부터 가공할 데이터를 1차적으로 정리했다.

 

 

5. 운전자별로 정리하기

 

원본 데이터는 특별한 경우가 아니면 trip의 시간 순서대로 다른 차량들이 섞여 있다. 따라서, 운전자(차량)별로 무언가를 분석하려면 운전자별로 정리할 필요가 있다. 데이터 용량이 매우 크므로 파일 하나를 읽어서 운전자별로 파일명을 부여한 뒤 덧붙여쓰기 방식으로 데이터를 재집계한다. 작은 용량들을 끊임없이 쓰기 때문에 역시 SSD가 필수다.

 

##raw 파일을 carid 별로 정리한다.
##ssd에 저장해야 성능이 보장된다.

rm(list=ls())
gc()

memory.limit()

sourceFolder <- paste0("W:\\work\\202207_화물차\\01_raw\\")
fileNames = dir(sourceFolder, pattern = "*.*gz")
targetFolder <- paste0("W:\\work\\202207_화물차\\05_운전자별\\")

#library(doParallel)
library(Rcpp)
library(data.table)
library(dplyr)
library(rgdal)

for (i in c(1:length(fileNames))) {

  fileName <- paste0(sourceFolder,fileNames[i])
  
  system.time(raw <- fread(fileName,
                           #quote="\"",
                           encoding="UTF-8",
                           select = c(1:5), 
                           colClasses = list(#character = c(1,2,3),
                             integer=c(1,2,3),
                             numeric = c(4,5)),
                           col.names = c('tid', 'id','time', 'x', 'y'),
                           sep = "\t", header = TRUE, stringsAsFactors = FALSE,
                           #nThread = 5, verbose = F, 
                           tmpdir = "a:/temp/R/" #gz를 읽을 때 tempdir을 사용하므로, 가장 빠른 디스크로 지정해준다.
  )
  )
  raw <- subset(raw, select = c(id, tid, time, x, y))
  splitbyID <- split(raw, by = 'id')
  #length(splitbyID)
  
  print(paste0("저장 시작:",fileName))
  
  for (j in c(1:length(splitbyID))) {
    #j=1
    writeName <- sprintf("%05d",as.integer(names(splitbyID[j])))
    fwrite(splitbyID[[j]],
           file = paste0(targetFolder,writeName,".tsv"), 
           sep = "\t", row.names = FALSE, col.names = FALSE, append = TRUE)
    
  }
  
  print(paste0("저장 완료:",fileName))
  
  
}

rm(raw)

마지막 부분쯤 fwrite를 할 때, append=TRUE 부분이 핵심이다. 그래야 기존 파일을 지우지 않고 덧붙여쓰기를 한다.

 

 

6. 운전자별 데이터를 요약하기

 

이제 운전자별로 정리된 파일을 하나씩 읽어서 trip 단위로 정리한다. 즉, 한 운전자가 한달동안 90번의 trip을 수행했다면, 그 운전자 데이터는 (90행+알파)로 요약된다. '알파' 부분은 trip을 다시 잘게 쪼개기 때문에 발생하는데, 바로 밑에서 다시 설명하겠다.

##운전자별 파일을 읽어서 운행에 대한 요약본 만들기
##개별 trip 중 중간에 휴지기가 있는 이동들을 분할해서 subid로 만든다.

gc()
library(doParallel)
memory.limit()

sourceFolder <- paste0("W:\\work\\202207_화물차\\05_운전자별\\")
fileNames = dir(sourceFolder, pattern = "*.*tsv")
targetFolder <- paste0("W:\\work\\202207_화물차\\06_운전자정보\\")

myCluster <- parallel::makePSOCKcluster(20)

rcppinit <- function() {
  library(data.table)
  library(dplyr)
}

clusterCall(myCluster, rcppinit)
doParallel::registerDoParallel(myCluster)

frun <- foreach(
  i = c(1:length(fileNames))
) %dopar% {  
  
  #i=1
  fileName <- paste0(sourceFolder,fileNames[i])
  
  system.time(raw <- fread(fileName,
                           #quote="\"",
                           encoding="UTF-8",
                           select = c(1:5), 
                           colClasses = list(integer=c(1,2,3),
                                             numeric = c(4,5)),
                           col.names = c('carid','tripid', 'time', 'x', 'y'),
                           sep = "\t", header = FALSE, stringsAsFactors = FALSE,
 )
  )
  
    data <- raw[, ':='(x1 = lag(x), y1 = lag(y), time1 = lag(time)),
      by = .(tripid, carid)][,':='(movedelta = sqrt((x-x1)*(x-x1)+(y-y1) * (y-y1))/(time-time1)),
      by = .(tripid, carid)][movedelta>2.78 & movedelta<50]
        
    data[, timedelta := time-lag(time), by=.(tripid, carid)]
    data[, cnt := ifelse(timedelta<600 | is.na(timedelta),0,1)]
    data[, subid := cumsum(cnt), by=.(carid, tripid)]
    
    for (j in c(1:3)) {
      data<-data[, ':='(x1 = lag(x), y1 = lag(y), time1 = lag(time)),
                   by = .(tripid, carid, subid)][,':='(movedelta = sqrt((x-x1)*(x-x1)+(y-y1) * (y-y1))/(time-time1)),
                                          by = .(tripid, carid, subid)]
      data <- data[movedelta>2.78 & movedelta<50]
      
      data[, timedelta := time-lag(time), by=.(tripid, carid)]
      data[, cnt := ifelse(timedelta<600 | is.na(timedelta),0,1)]
      data[, subid := cumsum(cnt), by=.(carid, tripid)]
    }
    
    data <- data[,':='(movedelta = sqrt((x-x1)*(x-x1)+(y-y1) * (y-y1))),
                 by = .(tripid, carid, subid)]

  tmp0 <- data[, .SD[1], by = c('tripid', 'carid', 'subid')] #각 그룹의 첫행
  
  tmp1 <- data[, .(rownum = .I[.N]), by = c('tripid', 'carid', 'subid')] #각 그룹의 마지막 행
  tmp1 <- data[, .SD[tmp1$rownum]]
  tmp2 <- data[, .(moveTotal = sum(movedelta)), by = list(tripid, carid, subid)] 
  
  tmp0 <- subset(tmp0, select = c(tripid, carid, subid, time))
  tmp1 <- subset(tmp1, select = c( time))
  tmp2 <- subset(tmp2, select = c( moveTotal))
  
  dataF <- cbind(tmp0, tmp1, tmp2)
  
  colnames(dataF) <- c('tripid','carid', 'subid', 'st', 'et', 'tripDist')  
  dataF[, tripTime := et-st]  
  
  writeName <- paste0(targetFolder, substr(fileNames[i],1,5), "_driver.tsv")
  fwrite(dataF, file = writeName,
         sep = "\t", row.names = FALSE, col.names = TRUE)

  print(fileName)
  
  return(dataF)
}

stopCluster(myCluster)

data <- rbindlist(frun)
rm(frun)
rm(raw)

setorder(data, carid, st)
fwrite(data, file= "W:\\work\\202207_화물차\\10_전체요약\\운전자정보_total.tsv", sep="\t")

데이터는 tripid, carid, subid, st, et, tripDist로 정리되었다.

tripid는 말그대로 trip의  key 값. carid는 차량별(운전자별) key 값, st는 trip 시작시각, et는 trip 도착시각, tripDist는 trip에서 움직인 총 거리다.

subid 부분은 하나의 trip을 다시 세분화한 덩어리들이다. 2.78과 50이 등장하는 부분이 바로 trip들을 sub-trip으로 분절해주는 부분이다. 어느정도의 탐색과 어느정도의 조작적 정의로 결정했다. 전처리가 좀 더 고도화된다면 이와 같은 부분을 수정해야 한다. 물론 엄밀하면 좋지만, 대용량 실제 데이터의 노이즈들을 제한된 환경과 제한된 시간에서 처리해야 할 때 이렇게 적당히 뭉개고 가는 부분들이 종종 발생하는 것 같다.

 

여기까지 해서 최종적으로 사용할 데이터셋 하나가 완성되었다.

 

 

 

7. 운전자 정보로부터 지도에 그리드별 통행 빈도 계산하기

 

## 이번에는 500m 그리드를 진행순서대로 기록하고, 빈도를 세어본다.

gc()
library(data.table)
library(dplyr)
library(doParallel)
sourceFolder <- "W:\\work\\202207_화물차\\05_운전자별\\"
fileNames = dir(sourceFolder, pattern = "*.tsv")

targetFolder <- "W:\\work\\202207_화물차\\09_운전자별격자\\"

targetTotal <-  "W:\\work\\202207_화물차\\10_전체요약\\"

myCluster <- makeCluster(20)

rcppinit <- function() {
  library(data.table)
  library(dplyr)
}

clusterCall(myCluster, rcppinit)

doParallel::registerDoParallel(myCluster)

frun <- foreach(
  i = 1:length(fileNames)
) %dopar% {
  
  #i=1
  fileName <- paste0(sourceFolder,fileNames[i])
  
  system.time(raw <- fread(fileName,
                           #quote="\"",
                           encoding="UTF-8",
                           select = c(1:5), 
                           colClasses = list(#character = c(1,2,3),
                             integer=c(1,2,3),
                             numeric = c(4,5)),
                           col.names = c('carid','tripid', 'time', 'x', 'y'),
                           sep = "\t", header = FALSE, stringsAsFactors = FALSE,                        
  )
  )
  
  raw[, ':='(xgrid = as.integer(x/500),
             ygrid = as.integer(y/500))]
  
  setorder(raw, time)
  data <- subset(raw, select=c(carid, xgrid, ygrid, time))
  
  #그 그리드의 가장 마지막 시간을 기록하기 위해 lead와 비교
  data[, diff := ifelse(xgrid!=lead(xgrid)|ygrid!=lead(ygrid), 1,0)] 
  
  data <- data[diff!=0 | is.na(diff)]
  data <- data[,c(1:4)]
  data[, time := time - 1648771200]
  writeName <- paste0(targetFolder, substr(fileNames[i], 1, 5),".tsv")
  fwrite(data, file=writeName, sep="\t")
  
  return (data)
}
stopCluster(myCluster)

data <- rbindlist(frun)
rm(frun)

fwrite(data, file= "W:\\work\\202207_화물차\\10_전체요약\\운전자별 그리드 진행순서대로.tsv", sep="\t")

dataSumById <- data[, .(cnt = .N),  by = .(carid, xgrid, ygrid)]
dataSumById[, ':='(xgrid = xgrid*500,
                   ygrid = ygrid*500)]

setorder(dataSumById, carid)
fwrite(dataSumById, file= paste0(targetTotal, "운전자별그리드별 빈도.tsv"), sep="\t")

dataSum <- data[, .(cnt = .N),  by = .(xgrid, ygrid)]
dataSum[, ':='(xgrid = xgrid*500,
                   ygrid = ygrid*500)]

fwrite(dataSum, file= paste0(targetTotal,"그리드별 빈도.tsv"), sep="\t")

운전자별로 데이터를 읽은 후, 세 가지 형식으로 가공해서 저장했다.

 

첫 번째는 차량의 진행궤적을 그리드별로 구분한 후 순서를 보존해서 저장한다.

두 번째는 운전자별 그리드별 빈도를 센다.

마지막으로 전체 그리드별 빈도를 센다.

 

 

8. 그림을 그려보자

 

이제 그릴 데이터가 모두 준비되었으므로 아래와 같은 그림을 그려보겠다.

왼쪽은 운전자별로 한 달의 시간표를 압축한 그림이다. 오른쪽은 5km 격자별로 운전자가 어느 곳을 얼마나 돌아다녔는지 표시한 그림이다.

 

 

targetFolder <- "W:\\work\\202207_화물차\\"
targetTotal <-  "W:\\work\\202207_화물차\\10_전체요약\\"
imageFolder <- "D:\\00. Working on\\202204_화물차 분석\\image\\"
gc()
#install.packages("ggplot2")
library(ggplot2)
library(lubridate)
library(data.table)
library(dplyr)

system.time(data <- fread("W:\\work\\202207_화물차\\10_전체요약\\운전자정보_total.tsv",
                          #quote="\"", encoding="unknown",
                          select = c(1:7), 
                          colClasses = list(#character = c(1,3),
                            integer=c(1:5,7),
                            numeric = c(6)),                         
                          sep = "\t", header = TRUE, stringsAsFactors = FALSE#, nThread = 20
)
)


### 읽었으면 데이터 정리. unix_time을 시각 형식으로 바꾼다.
setorder(data, carid, tripid, subid)

###### 아이디 단순화 시켜서 정리
data <- data %>% select(id=carid, st, et, tripDist, tripTime)


tripSummary <- data %>% group_by(id) %>%
  summarise(tripTime = sum(tripTime), tripDist = sum(tripDist)) %>%
  mutate(tripTime = tripTime/3600) %>%
  mutate(tripDist = tripDist/1000)

setDT(tripSummary)
setkey(tripSummary, id)

library(Rcpp)
sourceCpp("D:/GitHub/R/smallProjects/yymmddConverter.cpp")


## 데이터 재구조화 - 출발 도착 시각을 한 줄로 섞는다. 도넛 그래프 그리기 위함
data1 <- data %>% select(id, st)
data2 <- data %>% select(id, st = et)
dataNew <- rbindlist(list(data1, data2))
setDT(dataNew)
setorder(dataNew, id, st)

dataNew[, st := st-1648771200]
dataNew[, st := as.integer(st / (3600*24))]

# 마지막 날짜에서 첫 날짜 뺀게 23일 이상일 때 (금토일, 토일월 연휴를 고려하면 5일(화)부터 23일(목)까지 정도가 될 것같음 )
dataSE <- dataNew[, .(sd=min(st),
                      ed=max(st)),
                  by=.(id)]
dataSE[, diff := ed-sd]

dataUnq <- unique(dataNew, by = c('id', 'st'))
dataUnq <- dataUnq[, .(cnt=.N), by = .(id)]

dataFinal <- merge(dataSE, dataUnq, by='id')

workDays <- dataFinal[, .(count = .N), by=.(cnt)]

#평균 19일
mean(dataFinal$cnt)

beginToEndDays <- dataFinal[, .(count = .N), by=.(diff)]

dataFiltered <- dataFinal[diff>=25 & cnt>=21]

rm(beginToEndDays, data1, data2, data3, dataFiltered1, dataFinal, dataNew, dataSE, dataUnq, workDays)


data <- data[id %in% dataFiltered$id]

data1 <- data %>% select(id, st)
data2 <- data %>% select(id, st = et)
data3 <- data %>% select(id) %>% distinct(id)
data3$st <- ymdhmsVWL2("22040100000000")

ymdhmsVWL2("22040100000000")

dataNew <- rbindlist(list(data1, data2, data3))
setDT(dataNew)
setorder(dataNew, id, st)


############################## 여기까지 하고 뒤로 넘긴다.


#한달의 시각 기록을 0.0 ~ 1.0 으로 만든다.
dataNew[, st := st-1648771200]
dataNew[, st := st / (3600*24*30)]
dataNew[, et := lead(st), by = id]

dataNew <- dataNew[, et := ifelse(is.na(et), 1.0, et)]

dataNew[, type := seq_len(.N), by = id]
dataNew[, type := ifelse(type%%2==0, "주행", "휴식")]

#drive, rest 구분 상세
{
  dataNew[, duration := (et-st) * (3600*24*30) / 60]
  dataNew[duration<15 & type=='휴식', type := '휴식_15분미만']
  dataNew[duration>480 & type=='휴식', type := '휴식_8시간이상']
  dataNew[duration>120 & type=='주행', type := '주행_2시간이상']  
  targetFolder <- "W:\\work\\202207_화물차\\07_운전자정보_그림_15분이하휴식삭제_지도함께_필터링_오류수정\\"
}


dataNew[, rad := 4]  
dataNew[type =='주행_2시간이상', rad :=3.5]
dataNew[type =='주행', rad :=3.8]
dataNew[type =='휴식_15분미만', rad :=3.8]
dataNew[type =='휴식', rad :=4]
dataNew[type =='휴식_8시간이상', rad :=4.05]

#dataNew[,col :='cyan']

rm(data1, data2, data3, data, dataFiltered)



################ 이번에는 지도에 그릴 데이터 중 유효한 것을 필터링한다.

system.time(freqTbl <- fread(paste0("W:\\work\\202207_화물차\\10_전체요약\\운전자별 그리드 진행순서대로.tsv"),
                             #quote="\"",
                             #encoding="UTF-8",
                             select = c(1:3), 
                             colClasses = list(
                               integer =c(1:3)
                             ),
                             #col.names = c('carid','tripid', 'time', 'x', 'y'),
                             sep = "\t", header = TRUE, stringsAsFactors = FALSE,                            
)
)

freqTbl[,':='(xgrid = xgrid*500,
              ygrid = ygrid*500)]
freqTbl <- freqTbl[xgrid !=954500 | ygrid!=1778000]

freqTbl[, ':='(xgrid = as.integer(xgrid/5000),
               ygrid = as.integer(ygrid/5000))]


freqTbl[, diff := ifelse(xgrid!=lag(xgrid)|ygrid!=lag(ygrid), 1,0), by = .(carid)]

head(freqTbl)
data <- freqTbl[diff!=0 | is.na(diff)]
data <- data[,c(1:3)]
data <- data %>%select(id=carid, xgrid, ygrid)

#fwrite(data, file=paste0(targetTotal, "운전자별 그리드 진행순서대로_5000미터격자.tsv"), sep="\t")

data[, diffsum := abs(xgrid-lag(xgrid)) + abs(ygrid-lag(ygrid)), by = .(id)]


fault <- data[diffsum>=3]
faultSum <- fault[, .(cnt=.N), by=.(id)]
faultSumFiltered <- faultSum[cnt<=5]

data <- data[id %in% faultSumFiltered$id]

#fwrite(data, file=paste0(targetTotal, "운전자별 그리드 진행순서대로_5000미터격자_튀지 않은 것들만.tsv"), sep="\t")

data[, ':='(xgrid = (xgrid)*5000+2500,
            ygrid = (ygrid)*5000+2500)]

freqTbl <- data[, .(cnt = .N), by = .(id, xgrid, ygrid)]

setkey(freqTbl, id)

########### 이제 마지막으로 필터링하고, split한다.
dataNew <- dataNew[id %in% freqTbl$id]


uniqueID <- unique(dataNew %>% select(id), by="id")
fwrite(uniqueID, file="D:\\00. Working on\\202204_화물차 분석\\20221106_보낸파일\\다시그린그림\\유효id.tsv")

############################ ############################ ############################ 

rm(faultSum, fault, falutSum, faultSumFiltered)
gc()
## https://r-graph-gallery.com/128-ring-or-donut-plot.html
## 모든 운전자들의 도넛 그래프 그리기

library('Cairo')
CairoWin()
detach("package:Cairo", unload=TRUE)

library(ggpubr)
library(data.table)
library(dplyr)
library(ggplot2)
library(rgdal)
library(sf)
library('Cairo')
CairoWin()
library(showtext)
library(ggpubr)
theme_set(theme_minimal())
font_add(family="kopubbold", regular="W:\\temp\\KoPubWorld Dotum Bold.ttf")
showtext_auto()
showtext_opts(dpi=300)
system.time( sido <- "A:\\work\\gis\\시도.shp" %>% read_sf())
sido <- fortify(sido)


drawDrive <- function (targetFolder, splitbyID, freqTbl, tripSummary) {
 
  dataLen <- length(splitbyID)  
  
  theme_set(theme_minimal())
  for (i in c(1:dataLen)) {  

    num <- splitbyID[[i]][[1]][1]
    
    gridFreq <- freqTbl[id==num]
    
    summ <- tripSummary[id==num]
    triptime <- paste0("30일 주행 시간 : ", as.integer(summ[[2]][1]), "시간 (1일 평균 : ", round(summ[[2]][1]/30,1),"시간)")
    tripDist <- paste0("30일 주행 거리 : ", as.integer(summ[[3]][1]), "km (1일 평균 : ", round(summ[[3]][1]/30,1),"km)")
    a<-ggplot() +
      theme_set(theme_minimal())+
      geom_rect(splitbyID[[i]],
                mapping = aes(ymax=et*30, ymin=st*30, xmax=rad, xmin=3, fill = type)) +
      labs(title=paste0(sprintf("%05d",num)),
           subtitle = paste0(triptime,"\n",tripDist ),
           fill="",
           size=38)+
      scale_fill_manual(values=c( "주행_2시간이상"="#D6364A",
                                  "주행"="#FAC207",
                                  "휴식_15분미만"="#000000",
                                  "휴식"="#517C99",
                                  "휴식_8시간이상"="#2E3957")) +
      coord_polar(theta="y") + # Try to remove that to understand how the chart is built initially
      xlim(c(2, 4.1))+ # Try to remove that to see how to make a pie chart
      #scale_x_continuous(labels= c(1:30))+
      scale_y_continuous(breaks=seq(1,30,1),
                         labels= c(2:30, 1)) +
      
      theme(plot.title = element_text(hjust = 0.5),
            plot.subtitle = element_text(hjust = 0.5),
            legend.position = c(0.5,0.52),
            
            text=element_text(family="kopubbold"),
            axis.text.x = element_text(size=18, face='bold'),
            axis.text.y = element_blank(),
            axis.ticks.x = element_line(c(0,13,20)))
    
    b<-ggplot() +
      geom_sf(data = sido, color = '#aeaeae') +
      theme_set(theme_minimal())+
      geom_point(gridFreq,
                 mapping=aes(x=xgrid, y=ygrid,fill = cnt), size=1, shape = 21)+
      scale_fill_continuous(type = "viridis",trans = 'reverse')+
      theme_bw()+
      theme(axis.text.x = element_blank(),
            axis.text.y = element_blank(),
            axis.title.x=element_blank(),
            axis.title.y=element_blank())       
    
    g <- ggarrange(a,b)  
    
    fileName <- paste0(targetFolder,sprintf("%07d",num),".png")
    
    #num <- splitbyID[[i]][[1]][1]
    ggsave(fileName,
           plot = g,
           width = 300, height = 150, unit = c("mm"),  dpi = 300, type = 'cairo')
    
    print(i)
    
  } 


}

전반부에서는 500m격자 데이터를 5000m 기준으로 변환하는 등,  데이터를 처리하고, 중간 이후부터 ggplot2로 그리기 시작했다. 파이 차트를 그리기 전에 한 달의 시간 단위를 0~1 사이의 범위로 변환했다. 그 밖에도 그림을 그리기 위해 몇 가지 데이터를 변환한 부분들이 있다.

 

왼쪽 시간표는 a 변수에 넣고, 지도 위의 격자는 b 변수에 넣은 후, ggarrange 함수로 병치 결합시켰다.

세세한 부분들은 코드 안에 들어 있다.

 

이 작업 결과 중 일부는 시사IN 기사 <화물차를 쉬게 하라>에 사용되었다.

 

요일도 밤낮도 없는 화물차 기사의 24시간 365일 노동 [DTG 데이터 탐사보도②] - 시사IN

상상해보라. 당신이 만약 밤 10시쯤 퇴근해 다음 날 새벽 6시에 다시 출근한다면. 이 정도 연속휴식조차 취할 수 있는 날이 일주일에 한 번이라면, 혹은 한 달에 한 번이라면, 혹은 한 번도 없다면

www.sisain.co.kr

 

 

9. 웹 용으로 다시 그리기

 

시사IN 기사는 웹 용으로도 퍼블리싱 되었다.

 

 

시사IN x VWL 특별기획 화물차를 쉬게 하라

DTG 데이터로 본 365일 24시간의 노동

truck.sisain.co.kr

웹 작업을 위해 파이차트를 다시 그렸다. 아무래도 R의 표현은 제한적이기 때문에 d3.js 로 표현했다.

코드 전체를 옮길 수는 없지만, 아래 함수가 파이 차트를 그리는 함수다. driverSchedule가 바로 위에서 정리한 데이터와 거의 같은 형식의 운전자별 trip 요약 정보다. scrollT는 스크롤을 얼마나 긁었는지 나타내는 Y값이다. 

 

  // https://observablehq.com/@d3/pie-chart
function PieChart30days(driverSchedule, scrollT, sampleNum) {
  
  
    //시간재조정
    let {time, opacity} = sampleNum==99999? reMapTime2(scrollT) 
                    : reMapTime3(scrollT);

    const data = driverSchedule.get(sampleNum);
    //console.log("const driverSchedule = ",driverSchedule);
    //console.log("const data = ",sampleNum, data);
    var svg1 = document.getElementById('svg1');

    let {width, w, xCen, yCen} = getSizeAndLocation(svg1, true);
    if (sampleNum==99999) {
      xCen = svg1.clientWidth *0.5;
      yCen = svg1.clientHeight/2;
    } 

    //const w = svg1.clientWidth ;
    //console.log(w);
    //const width = Math.min(w/5,150); // outer width, in pixels
    //const height = w/5; // outer height, in pixels
    const innerRadius = width*0.95; // inner radius of pie, in pixels (non-zero for donut)
    const outerRadius = width/ 2; // outer radius of pie, in pixels
    //console.log("innerRadius :", innerRadius);
    const stroke = 0;//innerRadius > 0 ? "none" : "white"; // stroke separating widths
    const strokeWidth = 1; // width of stroke separating wedges
    const strokeLinejoin = "round"; // line join of stroke separating wedges
    const padAngle = stroke === "none" ? 1 / outerRadius : 0; // angular separation between wedges
    const pieRadius = [3.3, 3.3, 3.8, 4, 4];
    const pieColor = ["#D6364A", "#FAC207", "#000000", "#333333", "#101010",
     "#999999","#999999","#999999", "#000000"];
  
    //  const width_ = svg1.clientWidth ;
    //  const height_ = svg1.clientHeight;


    //const pieRadius = [4,4,4,4,4];
    //const pieColor = ["#D6364A", "#D6364A", "#D6364A", "#D6364A", "#D6364A"];
    
    function p(r) {
      return r / 140 * innerRadius;
    }
  
    const dayRange = time;// parseInt(time/100);
    //for (let day=0; day<30 ; day++) {
    
    if (time >0 && time<1) {
     
      vibrate200(time);         
    }
    //시간표
    const data2 = data.map((d) => {
      const day = parseInt(d.st);
      d.startAngle = (d.st - day) * Math.PI * 2;
      d.endAngle = (Math.min(time,d.et) -day) * Math.PI * 2;
      return d;
    });
    
    //주간야간
    const data3_Raw = new Array();
    for (let i=0 ; i<30 ; i++) {
      //data3_Raw.push({st: (i+0)/30, et : (i+6/24)/30, type : 4});
      data3_Raw.push({st: (i+6/24)/30, et : (i+18/24)/30, type : 3});
      data3_Raw.push({st: (i+18/24)/30, et : (i+1.25)/30, type : 4});
    }
    const data3 = data3_Raw.map((d) => {
      const day = parseInt(d.st);
      d.startAngle = (d.st - day) * Math.PI * 2;
      d.endAngle = (d.et -day) * Math.PI * 2;
      return d;
    });
  
    //평일휴일
    const data4_Raw = new Array();
    if (sampleNum==99999) {
      data4_Raw.push({st: (0)/30, et : (5-0.25)/30, type : 5});
      data4_Raw.push({st: (5-0.25)/30, et : (7+0.25)/30, type : 4});
      data4_Raw.push({st: (7+0.25)/30, et : (12-0.25)/30, type : 5});
      data4_Raw.push({st: (12-0.25)/30, et : (15+0.25)/30, type : 4});
      data4_Raw.push({st: (15+0.25)/30, et : (19-0.25)/30, type : 5});
      data4_Raw.push({st: (19-0.25)/30, et : (22+0.25)/30, type : 4});
      data4_Raw.push({st: (22+0.25)/30, et : (26-0.25)/30, type : 5});
      data4_Raw.push({st: (26-0.25)/30, et : (28+0.25)/30, type : 4});
      data4_Raw.push({st: (28+0.25)/30, et : (30)/30, type : 5});
    } else {
      data4_Raw.push({st: (0)/30, et : (1-0.25)/30, type : 5});
      data4_Raw.push({st: (1-0.25)/30, et : (3+0.25)/30, type : 4});
      data4_Raw.push({st: (3+0.25)/30, et : (8-0.25)/30, type : 5});
      data4_Raw.push({st: (8-0.25)/30, et : (10+0.25)/30, type : 4});
      data4_Raw.push({st: (10+0.25)/30, et : (15-0.25)/30, type : 5});
      data4_Raw.push({st: (15-0.25)/30, et : (17+0.25)/30, type : 4});
      data4_Raw.push({st: (17+0.25)/30, et : (22-0.25)/30, type : 5});
      data4_Raw.push({st: (22-0.25)/30, et : (24+0.25)/30, type : 4});
      data4_Raw.push({st: (24+0.25)/30, et : (29-0.25)/30, type : 5});
      data4_Raw.push({st: (29-0.25)/30, et : (30)/30, type : 4});
    }
    //data4_Raw.push({st: (29-0.25)/30, et : (30)/30, type : 4});
    
    const data4 = data4_Raw.map((d) => {
      const day = parseInt(d.st);
      d.startAngle = (d.st - day) * Math.PI * 2;
      d.endAngle = (d.et -day) * Math.PI * 2;
      return d;
    });
  
     //눈금
     const data5_Raw = new Array();
     for (let i=0 ; i<30 ; i++) {    
        if (i/30 > time) break;      
        data5_Raw.push({st: (i+0.0)/30, et : (i+0.15)/30, type : 8});
        data5_Raw.push({st: (i+0.0)/30, et : (i+0.01)/30, type : 7});
        data5_Raw.push({st: (i+0.0)/30, et : (i+0.05)/30, type : 4});
        data5_Raw.push({st: (i+0.25)/30, et : (i+0.27)/30, type : 5});
        data5_Raw.push({st: (i+0.50)/30, et : (i+0.52)/30, type : 6});
        data5_Raw.push({st: (i+0.75)/30, et : (i+0.77)/30, type : 5});        
     }
     const data5 = data5_Raw.map((d) => {
       const day = parseInt(d.st);
       d.startAngle = (d.st - day) * Math.PI * 2;
       d.endAngle = (d.et -day) * Math.PI * 2;
       return d;
     });
  
    svg.selectAll(".monthChart").remove();
  
  
      /////////////////// 눈금 ////////////////////////////
    const g_chart = svg.append("g")
                      .attr("class","monthChart")                
                      .attr("stroke", stroke)
                      .attr("stroke-width", strokeWidth)
                      .attr("stroke-linejoin", strokeLinejoin)
                      .attr("opacity", opacity)
                      .attr("transform", () => {                          
                          return "translate("+xCen+" "+yCen+")";
                      });
    const pieChartRef3 = g_chart.append("g")
                              .attr("class","ref3")
                              .selectAll("path")
                               .data(data5);
  
    pieChartRef3.enter()
        .append("path")
        .attr("class","chartRef3")
        .attr("fill", (d) => (pieColor[d.type]))
        ;
    pieChartRef3.exit().remove();                
  
    d3.selectAll(".chartRef3")          
      .attr("d", (d) => {
        let r = pieRadius[d.type];
        if (d.type==3) {
          const duration = (d.et-d.st)*30*60*24;
          r = ((duration-15)/(480-15)) * (pieRadius[4]-pieRadius[1]) + pieRadius[1];
        }
        //return d3.arc().innerRadius(117).outerRadius(127)(d);      
        return d3.arc().innerRadius(d.type>=7?p(105):p(320)).outerRadius(d.type==8? p(115) : (d.type==5? p(325) : p(330)))(d);        
    });  
  
    
    const dayArr = [];
    for (let i=1 ; i<=30 ; i++) {
      if ((i-1)/30 > time) break;      
      dayArr.push(i/30);
    }
  
    let textR = p(340);
    g_chart.selectAll(".daytext2")
      .data(dayArr).enter()
      .append("text")
      .attr("class","daytext2")
      .attr("dx", (d)=>textR*(Math.cos(d*Math.PI*2-Math.PI*(0.5+0.058))))
      .attr("dy", (d)=>textR*(Math.sin(d*Math.PI*2-Math.PI*(0.5+0.058))))
      .style("fill", "black")
      .attr("text-anchor", "middle") //좌우정렬
      .attr("alignment-baseline", "middle") //상하정렬
      .style("font-size", p(15))
      .style("opacity", 1)
      .text((d)=>{
        if (sampleNum==99999) return (parseInt(d*30)+17)%30+1;
        else return parseInt(d*30);
      });

    if (sampleNum==99999) {
      let dd = 0;
      textR = p(370);
      g_chart
        .append("text")
        .attr("class","daytext2")
        .attr("dx", textR*(Math.cos(dd*Math.PI*2-Math.PI*(0.5))))
        .attr("dy", textR*(Math.sin(dd*Math.PI*2-Math.PI*(0.5))))
        .style("fill", "black")
        .attr("text-anchor", "middle") //좌우정렬
        .attr("alignment-baseline", "middle") //상하정렬
        .style("font-size", p(20))
        .style("opacity", 1)
        .text("9월");

      dd = 0.4;
      g_chart
        .append("text")
        .attr("class","daytext2")
        .attr("dx", textR*(Math.cos(dd*Math.PI*2-Math.PI*(0.5))))
        .attr("dy", textR*(Math.sin(dd*Math.PI*2-Math.PI*(0.5))))
        .style("fill", "black")
        .attr("text-anchor", "middle") //좌우정렬
        .attr("alignment-baseline", "middle") //상하정렬
        .style("font-size", p(20))
        .style("opacity", 1)
        .text("10월");
    }
    /////////////////// 시간표 ////////////////////////////
    const rotate = time >=2.0? (scrollT-0.5) * 1000 : 0;
    const pieChartObj = g_chart.append("g")
                .attr("class","ref3")
                .attr("transform", () => {                          
                  return "translate("+(rotate==0? 0 : 1)*Math.cos(rotate)*p(6)+" "+Math.sin(rotate)*p(20)+") rotate("+rotate+")";
                })
                .selectAll("path")
                .data(data2.filter(d=>(d.st<=dayRange)));
    console.log("scrollT",scrollT);
    //console.log("sampleNum",sampleNum);
    pieChartObj.enter()
                .append("path")
                .attr("class","chartPath")

                .attr("opacity", (d)=> {
                  let a = 1;
                   if (sampleNum==30311) {                    
                    if (scrollT>=0.3 && scrollT<0.4) {
                      if (d.type==4) {
                        a = Math.sin(Math.PI*(scrollT-0.4)*100);
                        a = a*0.5+0.5;
                      //console.log("a:",a);
                      }
                    } else if (scrollT>=0.50 && scrollT<0.62) {
                      if (d.type<=1) {
                        a = Math.sin(Math.PI*(d.st-scrollT)*100);
                        a = a*0.5+0.5;
                      }
                    } else if (scrollT>=0.64 && scrollT<0.77) {
                      if (d.type==0) {
                        a = Math.sin(Math.PI*(scrollT-0.62)*100);
                        a = a*0.5+0.5;
                      //console.log("a:",a);
                      }
                    } 
                   } else if (sampleNum==30300) {
                    if (scrollT>=0.15 && scrollT<0.338) {
                      if (d.type==4) {
                        a = Math.sin(Math.PI*(scrollT-0.15)*100);
                        a = a*0.5+0.5;
                      //console.log("a:",a);
                      }
                    } else if (scrollT>=0.35 && scrollT<0.80) {
                      if (d.st>=4/30 && d.et<10/30) {
                        a = Math.sin(Math.PI*(d.st-scrollT)*100);
                        a = (a+1.0)*0.25+0.5;
                        //console.log("a:",a);
                      }
                      
                    } 
                   } else if (sampleNum==989) {
                    if (scrollT>=0.09 && scrollT<0.23) {
                      if (d.type==4) {
                        a = Math.sin(Math.PI*(scrollT-0.09)*100);
                        a = a*0.5+0.5;
                      //console.log("a:",a);
                      }
                    } else if (scrollT>=0.28 && scrollT<0.44) {
                      if (d.type==0) {
                        a = Math.sin(Math.PI*(scrollT-0.28)*100);
                        a = a*0.5+0.5;
                      //console.log("a:",a);
                      }
                    } else if (scrollT>=0.45 && scrollT<0.77) {
                      if (d.type==0 && d.st>(4.20/30) && d.st<(4.30/30)) {
                        a = Math.sin(Math.PI*(scrollT-0.45)*100);
                        a = a*0.5+0.5;
                      //console.log("a:",a);
                      }
                    } 




                   }
                   return a;
                })
                .attr("fill", (d) =>{
                   return pieColor[d.type];
                   
                  });
                
    pieChartObj.exit().remove();                
    
    d3.selectAll(".chartPath")          
        .attr("d", (d) => {
          let r = pieRadius[d.type];
          if (d.type==3) {
            const duration = (d.et-d.st)*30*60*24;
            r = ((duration-15)/(480-15)) * (pieRadius[4]-pieRadius[1]) + pieRadius[1];
          }
          return d3.arc().innerRadius(innerRadius).outerRadius(outerRadius*r)(d);        
        });  
  
    /////////////////// 주간야간 ////////////////////////////
    const pieChartRef = g_chart.append("g")
                          .attr("class","ref3")
                          .attr("transform", () => {                          
                            return "translate("+(rotate==0? 0 : 1)*Math.cos(rotate)*p(6)+" "+Math.sin(rotate)*p(20)+") rotate("+rotate+")";
                          })
                          .selectAll("path")
                          .data(data3);
  
    pieChartRef.enter()
          .append("path")
          .attr("class","chartRef")
          .attr("fill", (d) => (pieColor[d.type]))
          ;
    pieChartRef.exit().remove();                
  
    d3.selectAll(".chartRef")          
      .attr("d", (d) => {
        let r = pieRadius[d.type];
        return d3.arc().innerRadius(d.type==3? p(135) : p(127)).outerRadius(p(137))(d);      
        //return d3.arc().innerRadius(300).outerRadius(d.type==3? 301 : 310)(d);        
      });  
  
        
    /////////////////// 평일휴일 ////////////////////////////
    const pieChartRef2 = g_chart.append("g")
                          .attr("class","ref3")
                          .attr("transform", () => {                          
                            return "translate("+(rotate==0? 0 : 1)*Math.cos(rotate)*p(6)+" "+Math.sin(rotate)*p(20)+") rotate("+rotate+")";
                          })
                          .selectAll("path")
                            .data(data4);
  
    pieChartRef2.enter()
        .append("path")
        .attr("class","chartRef2")
        .attr("fill", (d) => (pieColor[d.type]))
        ;
    pieChartRef2.exit().remove();                
  
    d3.selectAll(".chartRef2")          
    .attr("d", (d) => {
      let r = pieRadius[d.type];
      return d3.arc().innerRadius(p(117)).outerRadius(p(127))(d);      
      //return d3.arc().innerRadius(300).outerRadius(d.type==3? 301 : 310)(d);        
    });  
  
    //////////////////  범례   //////////////////////////////////////
 
     const legend = g_chart.append("g")
                      .attr("class","ref3")
                      .attr("transform", () => {                          
                        return "translate("+(-p(70))+" "+(-p(40))+") scale(0.6)";
                      });
     const colArr = [1,0,3,2];
     const lgArr = ["2시간 이하 주행","2시간 초과 연속주행","15분~8시간 미만 정차","8시간 이상 연속정차"];
     for (let i=0 ; i<4; i++) {
      legend.append("rect")
              .attr("class","chartLegend")
              .attr("x",0)
              .attr("y", i*p(35))
              .attr("width",p(30))
              .attr("height",p(30))
              .attr("fill", (pieColor[colArr[i]]))
              ;

      legend.append("text")
            .attr("class","daytext2")
            .attr("dx", p(35))
            .attr("dy", i*p(35)+p(22))
 
            .attr("text-anchor", "left") //좌우정렬
            .attr("alignment-baseline", "top") //상하정렬
            .style("font-size", p(25))
            .style("opacity", 1)
            .text(lgArr[i]);
     }
}

시사IN 인터랙티브 기사에는 위의 함수를 이용한 네 개의 차트가 등장한다. 경우에 따라 효과를 다르게 부여해야 했으므로 sampleNum을 확인하고 if절로 분기하여 각각의 효과를 처리했다. R에서 그렸던 차트에 비해 눈금이 더 많이 들어가 있다.

 

 

 

코드 처리는 이 정도로 하고 2부에서는 시간표 차트 이야기를 해보겠다.